Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs/sql-transactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ User-defined unit-of-work wrappers can opt into the same classification through
effect-contract schema v2. Unclassified schema-v1 begin contracts remain
explicitly `none`; method spelling never supplies scope.

A second explicit option adds strictly lexical stage-to-boundary and
context-manager exit evidence:
A second explicit option adds strictly lexical stage-to-flush, stage-to-outcome,
and context-manager exit evidence:

```yaml
analysis:
Expand All @@ -47,8 +47,11 @@ analysis:
sql_transaction_path_max_pairs: 1024
```

An ordered path requires exact audited stage and commit/rollback occurrences in
the same source file, direct function body, and lexical order. Both calls must
An ordered path requires exact audited stage and flush/commit/rollback
occurrences in the same source file, direct function body, and lexical order.
A stage-to-flush path establishes only that pending SQL may be issued before a
later transaction outcome; it never promotes the evidence to durable persistence.
Both calls must
use the same finite `Name`/`Attribute` receiver expression, with no intervening
assignment to that expression or one of its lexical ancestors. A nearest prior
same-receiver `begin` may be attached together with its declared transaction or
Expand Down
26 changes: 19 additions & 7 deletions src/fastapi_endpoint_detector/analyzer/sql_transaction_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
)

_MAX_SOURCE_BYTES = 2 * 1024 * 1024
_BOUNDARY = Literal["commit", "rollback"]
_BOUNDARY = Literal["flush", "commit", "rollback"]
_REASON = Literal[
"different_source_scope",
"source_call_unavailable",
Expand Down Expand Up @@ -453,6 +453,7 @@ def build_sql_transaction_path_diagnostics( # noqa: PLR0912, PLR0915
len(item.stage_occurrence_ids)
* (
len(item.begin_occurrence_ids)
+ len(item.flush_occurrence_ids)
+ len(item.commit_occurrence_ids)
+ len(item.rollback_occurrence_ids)
)
Expand All @@ -468,6 +469,7 @@ def build_sql_transaction_path_diagnostics( # noqa: PLR0912, PLR0915
for evidence in transaction_report.endpoint_evidence
for occurrence_id in (
*evidence.stage_occurrence_ids,
*evidence.flush_occurrence_ids,
*evidence.begin_occurrence_ids,
*evidence.commit_occurrence_ids,
*evidence.rollback_occurrence_ids,
Expand All @@ -482,10 +484,9 @@ def build_sql_transaction_path_diagnostics( # noqa: PLR0912, PLR0915
paths: list[SQLTransactionOrderedPath] = []
context_paths: list[SQLTransactionContextPath] = []
diagnostics: list[SQLTransactionPathDiagnostic] = []
limitations = (
common_limitations = (
"Ordering proves only lexical source order in one direct function body; runtime "
"execution, exceptions, aliases, and transaction identity are not established.",
"A reachable ordered commit is not proof of commit success or durable persistence.",
"Receiver equality is a stable finite source expression, not runtime object identity.",
)
for evidence in transaction_report.endpoint_evidence:
Expand All @@ -499,9 +500,11 @@ def build_sql_transaction_path_diagnostics( # noqa: PLR0912, PLR0915
)
begins = tuple(occurrence_by_id[item] for item in evidence.begin_occurrence_ids)
begin_scope_by_id = {item.occurrence_id: item.scope for item in evidence.begin_scopes}
boundaries: tuple[tuple[str, _BOUNDARY], ...] = tuple(
(item, "commit") for item in evidence.commit_occurrence_ids
) + tuple((item, "rollback") for item in evidence.rollback_occurrence_ids)
boundaries: tuple[tuple[str, _BOUNDARY], ...] = (
tuple((item, "flush") for item in evidence.flush_occurrence_ids)
+ tuple((item, "commit") for item in evidence.commit_occurrence_ids)
+ tuple((item, "rollback") for item in evidence.rollback_occurrence_ids)
)
for stage_id in evidence.stage_occurrence_ids:
stage = contexts.get(stage_id)
for boundary_id, boundary_kind in boundaries:
Expand Down Expand Up @@ -621,7 +624,16 @@ def build_sql_transaction_path_diagnostics( # noqa: PLR0912, PLR0915
stage_occurrence_id=stage_id,
boundary_occurrence_id=boundary_id,
boundary=boundary_kind,
limitations=limitations,
limitations=(
*common_limitations,
(
"A reachable ordered flush may issue pending SQL but is not proof "
"of transaction commit or durable persistence."
if boundary_kind == "flush"
else "A reachable ordered commit or rollback is not proof of "
"boundary success or durable persistence."
),
),
)
)
unique_paths = {item.id: item for item in paths}
Expand Down
5 changes: 4 additions & 1 deletion src/fastapi_endpoint_detector/models/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ def validate_sql_transaction_path_report(self) -> "AnalysisReport": # noqa: PLR
for evidence in transaction_report.endpoint_evidence
for stage_id in evidence.stage_occurrence_ids
for boundary_id in (
*evidence.flush_occurrence_ids,
*evidence.commit_occurrence_ids,
*evidence.rollback_occurrence_ids,
)
Expand All @@ -522,7 +523,9 @@ def validate_sql_transaction_path_report(self) -> "AnalysisReport": # noqa: PLR
raise ValueError("SQL ordered path references unknown endpoint evidence")
begin_scope_by_id = {item.occurrence_id: item.scope for item in evidence.begin_scopes}
expected_boundaries = (
evidence.commit_occurrence_ids
evidence.flush_occurrence_ids
if path.boundary == "flush"
else evidence.commit_occurrence_ids
if path.boundary == "commit"
else evidence.rollback_occurrence_ids
)
Expand Down
16 changes: 11 additions & 5 deletions src/fastapi_endpoint_detector/models/sql_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ class SQLTransactionPathError(ValueError):
class SQLTransactionOrderedPath(_StrictModel):
"""One same-scope, same-receiver straight-line stage-to-boundary relation."""

schema_version: Literal[2] = 2
schema_version: Literal[3] = 3
id: Digest
endpoint_id: Digest
file_path: str = Field(min_length=1)
Expand All @@ -248,7 +248,7 @@ class SQLTransactionOrderedPath(_StrictModel):
begin_scope: TransactionScope | None = None
stage_occurrence_id: Digest
boundary_occurrence_id: Digest
boundary: Literal["commit", "rollback"]
boundary: Literal["flush", "commit", "rollback"]
ordering: Literal["same_scope_straight_line"] = "same_scope_straight_line"
status: Literal["ordered_boundary_reachable"] = "ordered_boundary_reachable"
persistence_status: Literal["not_established"] = "not_established"
Expand Down Expand Up @@ -343,6 +343,7 @@ class SQLTransactionPathDiagnostic(_StrictModel):

class SQLTransactionPathSummary(_StrictModel):
ordered_paths: int = Field(ge=0)
ordered_flushes: int = Field(ge=0)
ordered_commits: int = Field(ge=0)
ordered_rollbacks: int = Field(ge=0)
context_manager_paths: int = Field(ge=0)
Expand All @@ -352,7 +353,10 @@ class SQLTransactionPathSummary(_StrictModel):

@model_validator(mode="after")
def validate_counts(self) -> SQLTransactionPathSummary:
if self.ordered_commits + self.ordered_rollbacks != self.ordered_paths:
if (
self.ordered_flushes + self.ordered_commits + self.ordered_rollbacks
!= self.ordered_paths
):
raise ValueError("ordered SQL path counts are inconsistent")
if self.context_transactions + self.context_savepoints != self.context_manager_paths:
raise ValueError("context-managed SQL path counts are inconsistent")
Expand All @@ -362,7 +366,7 @@ def validate_counts(self) -> SQLTransactionPathSummary:
class SQLTransactionPathReport(_StrictModel):
"""Content-addressed bounded straight-line and context-exit evidence."""

schema_version: Literal[3] = 3
schema_version: Literal[4] = 4
status: Literal["diagnostic_only"] = "diagnostic_only"
effect_audit_hash: Digest
transaction_report_hash: Digest
Expand Down Expand Up @@ -400,6 +404,7 @@ def validate_report(self) -> SQLTransactionPathReport:
raise ValueError("SQL path diagnostics must be sorted and unique")
expected = SQLTransactionPathSummary(
ordered_paths=len(self.ordered_paths),
ordered_flushes=sum(item.boundary == "flush" for item in self.ordered_paths),
ordered_commits=sum(item.boundary == "commit" for item in self.ordered_paths),
ordered_rollbacks=sum(item.boundary == "rollback" for item in self.ordered_paths),
context_manager_paths=len(self.context_paths),
Expand Down Expand Up @@ -431,7 +436,7 @@ def build_sql_transaction_ordered_path(
receiver_hash: str,
stage_occurrence_id: str,
boundary_occurrence_id: str,
boundary: Literal["commit", "rollback"],
boundary: Literal["flush", "commit", "rollback"],
begin_occurrence_id: str | None = None,
begin_scope: TransactionScope | None = None,
limitations: tuple[str, ...],
Expand Down Expand Up @@ -535,6 +540,7 @@ def build_sql_transaction_path_report(
)
summary = SQLTransactionPathSummary(
ordered_paths=len(sorted_paths),
ordered_flushes=sum(item.boundary == "flush" for item in sorted_paths),
ordered_commits=sum(item.boundary == "commit" for item in sorted_paths),
ordered_rollbacks=sum(item.boundary == "rollback" for item in sorted_paths),
context_manager_paths=len(sorted_context_paths),
Expand Down
3 changes: 2 additions & 1 deletion src/fastapi_endpoint_detector/output/html_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -2617,7 +2617,8 @@ def format(self, report: AnalysisReport) -> str:
content_lines.append(
'<div class="summary-item"><span class="summary-label">'
"SQL Ordered Paths:</span> "
f"{paths.summary.ordered_paths} explicit boundaries / "
f"{paths.summary.ordered_paths} explicit boundaries "
f"({paths.summary.ordered_flushes} flushes) / "
f"{paths.summary.context_manager_paths} context exits / "
f"{paths.summary.unresolved_pairs} unresolved pairs; "
"lexical and conditional only, persistence not established</div>"
Expand Down
3 changes: 2 additions & 1 deletion src/fastapi_endpoint_detector/output/markdown_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ def format(self, report: AnalysisReport) -> str:
paths = report.sql_transaction_path_report
lines.append(
"- **SQL Ordered Paths:** "
f"{paths.summary.ordered_paths} explicit boundaries / "
f"{paths.summary.ordered_paths} explicit boundaries "
f"({paths.summary.ordered_flushes} flushes) / "
f"{paths.summary.context_manager_paths} context exits / "
f"{paths.summary.unresolved_pairs} unresolved pairs; "
"lexical and conditional only, persistence not established"
Expand Down
3 changes: 2 additions & 1 deletion src/fastapi_endpoint_detector/output/text_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ def format(self, report: AnalysisReport) -> str:
paths = report.sql_transaction_path_report
console.print(
" SQL Ordered Paths: "
f"{paths.summary.ordered_paths} explicit boundaries / "
f"{paths.summary.ordered_paths} explicit boundaries "
f"({paths.summary.ordered_flushes} flushes) / "
f"{paths.summary.context_manager_paths} context exits / "
f"{paths.summary.unresolved_pairs} unresolved pairs "
"(lexical and conditional only; persistence not established)"
Expand Down
34 changes: 31 additions & 3 deletions tests/integration/test_sql_transaction_semantics.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,11 @@ def _ordered_project(root: Path) -> tuple[Path, Path]:
" def begin(self) -> None: pass\n"
" def begin_nested(self) -> None: pass\n"
" def add(self, value: str) -> None: pass\n"
" def flush(self) -> None: pass\n"
" def commit(self) -> None: pass\n"
" def rollback(self) -> None: pass\n\n"
"class Other:\n"
" def flush(self) -> None: pass\n\n"
"class AsyncSession:\n"
" def begin(self): return self\n"
" async def __aenter__(self): return self\n"
Expand All @@ -223,7 +226,14 @@ def _ordered_project(root: Path) -> tuple[Path, Path]:
" session = Session()\n"
" session.begin()\n"
" session.add('ordered')\n"
" session.flush()\n"
" session.commit()\n\n"
"@app.post('/generic-flush')\n"
"def generic_flush() -> None:\n"
" session = Session()\n"
" other = Other()\n"
" session.add('generic')\n"
" other.flush()\n\n"
"@app.post('/nested')\n"
"def nested() -> None:\n"
" session = Session()\n"
Expand Down Expand Up @@ -328,7 +338,14 @@ def _ordered_project(root: Path) -> tuple[Path, Path]:
else {}
),
}
for operation in ("add", "begin", "begin_nested", "commit", "rollback")
for operation in (
"add",
"flush",
"begin",
"begin_nested",
"commit",
"rollback",
)
]
+ [
{
Expand Down Expand Up @@ -399,9 +416,10 @@ def test_ordered_paths_require_same_scope_receiver_and_straight_line(tmp_path: P
assert configured.orphan_changes == baseline.orphan_changes
paths = configured.sql_transaction_path_report
assert paths is not None
assert paths.schema_version == 3
assert paths.schema_version == 4
assert paths.summary.model_dump() == {
"ordered_paths": 3,
"ordered_paths": 4,
"ordered_flushes": 1,
"ordered_commits": 3,
"ordered_rollbacks": 0,
"context_manager_paths": 3,
Expand All @@ -415,6 +433,14 @@ def test_ordered_paths_require_same_scope_receiver_and_straight_line(tmp_path: P
"nested",
"ordered",
}
ordered_flush = next(
item
for item in paths.ordered_paths
if item.function_name == "ordered" and item.boundary == "flush"
)
assert ordered_flush.persistence_status == "not_established"
assert any("pending sql" in item.lower() for item in ordered_flush.limitations)
assert all(item.function_name != "generic_flush" for item in paths.ordered_paths)
assert ordered.begin_occurrence_id is not None
assert ordered.begin_scope is not None and ordered.begin_scope.value == "transaction"
nested = next(item for item in paths.ordered_paths if item.function_name == "nested")
Expand Down Expand Up @@ -460,6 +486,8 @@ def test_ordered_paths_require_same_scope_receiver_and_straight_line(tmp_path: P
for output_format in ("json", "yaml", "text", "markdown", "html"):
rendered = get_formatter(output_format).format(configured).lower()
assert "sql_transaction_path_report" in rendered or "sql ordered paths" in rendered
for output_format in ("text", "markdown", "html"):
assert "flushes" in get_formatter(output_format).format(configured).lower()


def test_ordered_paths_are_explicit_and_atomically_bounded(tmp_path: Path) -> None:
Expand Down
18 changes: 17 additions & 1 deletion tests/unit/test_sql_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
_HASH_B = f"sha256:{'b' * 64}"
_HASH_C = f"sha256:{'c' * 64}"
_HASH_D = f"sha256:{'d' * 64}"
_HASH_E = f"sha256:{'e' * 64}"


def test_outcome_is_derived_from_reachable_boundaries() -> None:
Expand Down Expand Up @@ -127,14 +128,29 @@ def test_ordered_paths_are_content_addressed_and_bounded() -> None:
boundary="commit",
limitations=("lexical ordering only",),
)
flush_path = build_sql_transaction_ordered_path(
endpoint_id=_HASH_A,
file_path="main.py",
function_name="handler",
receiver_hash=_HASH_B,
begin_occurrence_id=_HASH_C,
begin_scope=TransactionScope.TRANSACTION,
stage_occurrence_id=_HASH_B,
boundary_occurrence_id=_HASH_E,
boundary="flush",
limitations=("pending persistence only",),
)
report = build_sql_transaction_path_report(
_HASH_A,
_HASH_B,
(path,),
(path, flush_path),
(),
max_pairs=4,
)

assert report.schema_version == 4
assert flush_path.schema_version == 3
assert report.summary.ordered_flushes == 1
assert report.summary.ordered_commits == 1
assert report.max_pairs == 4
payload = report.model_dump(mode="json")
Expand Down
Loading