mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
480a1426b3
* fix(session): fail-closed history-commit handoff (#981) The deleted-workstream discovery is now a terminal, ws_id-keyed latch: keyed conversation commits refuse admission once the durable parent is gone (convergence finalizers and force-abandon are exempt), history handoff refuses to mint a proof token so /history fails closed with a 503 instead of silently wiping the pane, and the SSE stream carries a workstream_gone resync reason. Discarded commits leave a forensic log of commit keys and roles, never content. Conversation rows gain a commit_key (migration 071): keyed saves are idempotent under retry, validated against the full commit identity, and refused when they would cross a workstream deletion. The prune orphan category now requires a NULL alias plus a two-hour updated grace, with cutoffs computed at discovery time and carried into both dialects' rechecks. The mid-turn interjection queue is owner-partitioned with no per-site mode flags: pops take the acting principal's and unowned rows, other participants' rows are structurally retained, and enforcement lives at queue admission plus the shared before_spawn gates. The retraction ledger is bounded by open pop windows: pops open a window atomically with the queue delete, restores close their ids atomically with the ledger consume, every other exit closes through one helper, and misses for unheld ids record nothing. The workstream-gone latch refuses unattended wakes at all three gates (watcher spawn, claim, delivery pre-pop), and the retry dispatcher regained its pre-envelope cancel/error convergence net. Persistence-state reporting derives through the session bound to each UI instead of a registry lookup by id that failed open to healthy during tombstone retention. The dashboard roster no longer re-inserts ghost entries from trailing activity events, the history tool-outcome scan tolerates interleaved non-turn rows, and the shared handoff-deadline handle owns its own retirement. Single-sourced across call sites: keyed-commit row values, attachment save wrappers, tail-truncation and conflict-resolution bodies for both storage dialects; worker-slot lifecycle field sets; the direct-commit admission frame; queued-row layout accessors; the string-aware comment stripper shared by every JS harness suite. Refs #981 #964 * fix(session): sweep handoff fixes to their sibling surfaces The interactive replay loop treated a system row as a tool-batch boundary, so every tool result after an interleaved row vanished from that pane while the coordinator rendered the same history correctly. Only a conversational turn ends the batch window now, matching the shared outcome index. Accepted user turns clear the composer's attachment chips on the same viewer policy that settles optimistic bubbles rather than on having matched a local bubble, so a workstream created with an upload no longer keeps a chip for an attachment the create dispatch already consumed. The coordinator's raced-Stop arm emits the stream-end hook it inherits alongside the idle state, leaving no unfinalized bubble or unflushed tool output. Ending a session surfaces a failure toast when the request never lands or answers with a non-JSON body. The per-second persistence reconcile now probes each session without blocking: a workstream whose generation and handoff locks are held is skipped until the next pass instead of contending the locks every commit needs. The one-shot repair that gates workstream creation at capacity keeps a definite probe — it has no next pass, and the sessions likeliest to be contended are the ones whose unresolved journals emptied its candidate list. Single-sourced: the attachment lane builds its conversation row through the shared commit-identity builder; the ordinary worker exit releases its slot through the lifecycle owner; both operator surfaces snapshot their counters through one non-consuming helper; the replay preamble loses its per-kind wrappers and its config hook; the browser harness suites share one brace walker; and each in-flight history attempt is one record carrying both its abort controller and its deadline. Refs #981 #964
536 lines
18 KiB
Python
536 lines
18 KiB
Python
"""Atomic, idempotent TOOL-row plus attachment persistence coverage."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import nullcontext
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from sqlalchemy.dialects import postgresql
|
|
|
|
from tests._storage_fakes import make_attachment
|
|
from turnstone.core import memory
|
|
from turnstone.core.storage import (
|
|
AttachmentWrite,
|
|
ConversationCommitConflictError,
|
|
ConversationCommitWorkstreamGoneError,
|
|
_utils,
|
|
)
|
|
from turnstone.core.storage._postgresql import PostgreSQLBackend
|
|
|
|
|
|
def _attachment(
|
|
attachment_id: str,
|
|
content: bytes,
|
|
*,
|
|
filename: str = "tool-image.png",
|
|
mime_type: str = "image/png",
|
|
kind: str = "image",
|
|
) -> AttachmentWrite:
|
|
return make_attachment(
|
|
attachment_id,
|
|
content,
|
|
filename=filename,
|
|
mime_type=mime_type,
|
|
kind=kind,
|
|
)
|
|
|
|
|
|
def _commit(
|
|
backend: Any,
|
|
ws_id: str,
|
|
attachments: list[AttachmentWrite] | tuple[AttachmentWrite, ...],
|
|
*,
|
|
content: str = "Image file: result.png",
|
|
tool_name: str = "read_file",
|
|
tool_call_id: str = "call-image",
|
|
event_id: int | None = 29,
|
|
is_error: bool = False,
|
|
meta: str | None = '{"effect_status":"succeeded"}',
|
|
commit_key: str = "one-tool-result",
|
|
) -> int:
|
|
return backend.save_tool_message_with_attachments(
|
|
ws_id,
|
|
content,
|
|
tool_name,
|
|
tool_call_id,
|
|
attachments,
|
|
event_id=event_id,
|
|
is_error=is_error,
|
|
meta=meta,
|
|
commit_key=commit_key,
|
|
)
|
|
|
|
|
|
def test_identical_tool_retry_returns_same_row_without_refcount_replay(
|
|
storage_backend: Any,
|
|
) -> None:
|
|
backend = storage_backend
|
|
ws_id = "atomic-tool-retry"
|
|
backend.register_workstream(ws_id)
|
|
image = _attachment("a" * 64, b"image-bytes")
|
|
|
|
first_id = _commit(backend, ws_id, [image])
|
|
retry_id = _commit(backend, ws_id, [image])
|
|
|
|
assert retry_id == first_id
|
|
assert backend.count_messages(ws_id) == 1
|
|
blob = backend.get_attachment(image.attachment_id)
|
|
assert blob["refcount"] == 1
|
|
assert blob["origin"] == "tool"
|
|
turn = backend.load_message_turns(ws_id, checkpointed=False)[0]
|
|
assert turn.tool_call_id == "call-image"
|
|
assert turn.is_error is False
|
|
assert turn.meta.extra["storage_attachment_ids"] == [image.attachment_id]
|
|
|
|
|
|
def test_concurrent_identical_tool_retries_retain_once(storage_backend: Any) -> None:
|
|
backend = storage_backend
|
|
ws_id = "atomic-tool-concurrent"
|
|
backend.register_workstream(ws_id)
|
|
image = _attachment("b" * 64, b"same-result")
|
|
barrier = threading.Barrier(3)
|
|
|
|
def _write() -> int:
|
|
barrier.wait(timeout=10)
|
|
return _commit(backend, ws_id, [image])
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
first = pool.submit(_write)
|
|
second = pool.submit(_write)
|
|
barrier.wait(timeout=10)
|
|
row_ids = {first.result(timeout=10), second.result(timeout=10)}
|
|
|
|
assert len(row_ids) == 1
|
|
assert backend.count_messages(ws_id) == 1
|
|
assert backend.get_attachment(image.attachment_id)["refcount"] == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("override", "reverse"),
|
|
[
|
|
({"content": "changed"}, False),
|
|
({"tool_name": "open_preview"}, False),
|
|
({"tool_call_id": "call-other"}, False),
|
|
({"event_id": 30}, False),
|
|
({"is_error": True}, False),
|
|
({"meta": '{"effect_status":"unknown"}'}, False),
|
|
({}, True),
|
|
],
|
|
)
|
|
def test_same_key_tool_mismatch_fails_before_refcount_mutation(
|
|
storage_backend: Any,
|
|
override: dict[str, Any],
|
|
reverse: bool,
|
|
) -> None:
|
|
backend = storage_backend
|
|
ws_id = f"atomic-tool-conflict-{len(override)}-{reverse}-{next(iter(override), 'refs')}"
|
|
backend.register_workstream(ws_id)
|
|
first = _attachment("c" * 64, b"first")
|
|
second = _attachment("d" * 64, b"second")
|
|
_commit(backend, ws_id, [first, second])
|
|
|
|
attachments = [second, first] if reverse else [first, second]
|
|
with pytest.raises(ConversationCommitConflictError):
|
|
_commit(backend, ws_id, attachments, **override)
|
|
|
|
assert backend.count_messages(ws_id) == 1
|
|
assert backend.get_attachment(first.attachment_id)["refcount"] == 1
|
|
assert backend.get_attachment(second.attachment_id)["refcount"] == 1
|
|
|
|
|
|
def test_cancelled_preview_duplicate_refs_delete_exactly(storage_backend: Any) -> None:
|
|
backend = storage_backend
|
|
ws_id = "atomic-tool-cancelled-preview"
|
|
backend.register_workstream(ws_id)
|
|
preview = _attachment(
|
|
"e" * 64,
|
|
b"<html>preview</html>",
|
|
filename="preview.html",
|
|
mime_type="text/html",
|
|
kind="preview",
|
|
)
|
|
meta = json.dumps(
|
|
{
|
|
"effect_status": "unknown",
|
|
"preview": {"attachment_id": preview.attachment_id, "kind": "html"},
|
|
}
|
|
)
|
|
|
|
_commit(
|
|
backend,
|
|
ws_id,
|
|
[preview, preview],
|
|
content="Tool execution was cancelled before its outcome was observed.",
|
|
tool_name="open_preview",
|
|
tool_call_id="call-preview",
|
|
is_error=True,
|
|
meta=meta,
|
|
)
|
|
|
|
blob = backend.get_attachment(preview.attachment_id)
|
|
assert blob["refcount"] == 2
|
|
assert blob["kind"] == "preview"
|
|
assert blob["origin"] == "tool"
|
|
turn = backend.load_message_turns(ws_id, checkpointed=False)[0]
|
|
assert turn.is_error is True
|
|
assert turn.meta.extra["storage_attachment_ids"] == [
|
|
preview.attachment_id,
|
|
preview.attachment_id,
|
|
]
|
|
assert turn.meta.extra["preview"]["attachment_id"] == preview.attachment_id
|
|
|
|
assert backend.delete_workstream(ws_id) is True
|
|
assert backend.get_attachment(preview.attachment_id) is None
|
|
|
|
|
|
def test_hard_delete_then_tool_retry_refuses_row_and_refcount_recreation(
|
|
storage_backend: Any,
|
|
) -> None:
|
|
backend = storage_backend
|
|
ws_id = "atomic-tool-delete-retry"
|
|
backend.register_workstream(ws_id)
|
|
attachment = _attachment("8" * 64, b"deleted-tool")
|
|
_commit(backend, ws_id, [attachment])
|
|
assert backend.delete_workstream(ws_id) is True
|
|
|
|
with pytest.raises(RuntimeError, match="workstream no longer exists"):
|
|
_commit(backend, ws_id, [attachment])
|
|
# The facade propagates the typed permanence signal instead of swallowing
|
|
# it into the operational-failure 0: the durability journal classifies a
|
|
# deleted parent as terminal, never retrying.
|
|
with pytest.raises(ConversationCommitWorkstreamGoneError):
|
|
memory.save_tool_message_with_attachments(
|
|
ws_id,
|
|
"Image file: result.png",
|
|
"read_file",
|
|
"call-image",
|
|
[attachment],
|
|
event_id=29,
|
|
meta='{"effect_status":"succeeded"}',
|
|
commit_key="one-tool-result",
|
|
)
|
|
|
|
assert backend.count_messages(ws_id) == 0
|
|
assert backend.get_attachment(attachment.attachment_id) is None
|
|
assert backend.list_orphan_conversations() == []
|
|
|
|
|
|
def test_concurrent_tool_commit_and_delete_leave_no_row_or_refs(storage_backend: Any) -> None:
|
|
backend = storage_backend
|
|
ws_id = "atomic-tool-save-delete-race"
|
|
backend.register_workstream(ws_id)
|
|
attachment = _attachment("9" * 64, b"racing-tool")
|
|
barrier = threading.Barrier(3)
|
|
|
|
def _save() -> str:
|
|
barrier.wait(timeout=10)
|
|
try:
|
|
_commit(backend, ws_id, [attachment])
|
|
except RuntimeError as exc:
|
|
assert "workstream no longer exists" in str(exc)
|
|
return "refused"
|
|
return "saved"
|
|
|
|
def _delete() -> bool:
|
|
barrier.wait(timeout=10)
|
|
return backend.delete_workstream(ws_id)
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
save = pool.submit(_save)
|
|
delete = pool.submit(_delete)
|
|
barrier.wait(timeout=10)
|
|
assert save.result(timeout=10) in {"saved", "refused"}
|
|
assert delete.result(timeout=10) is True
|
|
|
|
assert backend.get_workstream(ws_id) is None
|
|
assert backend.count_messages(ws_id) == 0
|
|
assert backend.get_attachment(attachment.attachment_id) is None
|
|
assert backend.list_orphan_conversations() == []
|
|
|
|
|
|
def test_tool_partial_failure_rolls_back_row_blobs_refs_and_list(
|
|
storage_backend: Any,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Injection target follows the increment into the shared commit body.
|
|
|
|
Both backends now run the keyed attachment commit through
|
|
``_utils.save_attachment_commit_transaction``, so the refcount increment is
|
|
called on ``_utils`` rather than through the dialect module's re-export.
|
|
The property under test is unchanged: a failure after the increment must
|
|
leave no row, no new blob, and no changed refcount.
|
|
"""
|
|
backend = storage_backend
|
|
ws_id = "atomic-tool-rollback"
|
|
backend.register_workstream(ws_id)
|
|
existing = _attachment("1" * 64, b"existing")
|
|
new = _attachment("2" * 64, b"new")
|
|
backend.save_attachment(
|
|
existing.attachment_id,
|
|
existing.filename,
|
|
existing.mime_type,
|
|
existing.size_bytes,
|
|
existing.kind,
|
|
existing.content,
|
|
"tool",
|
|
)
|
|
real_retain = _utils.retain_attachment_refs
|
|
|
|
def _retain_then_fail(conn: Any, attachment_ids: list[str]) -> None:
|
|
real_retain(conn, attachment_ids)
|
|
raise RuntimeError("injected tool failure after refcount update")
|
|
|
|
monkeypatch.setattr(_utils, "retain_attachment_refs", _retain_then_fail)
|
|
with pytest.raises(RuntimeError, match="injected tool failure"):
|
|
_commit(backend, ws_id, [existing, new])
|
|
|
|
assert backend.count_messages(ws_id) == 0
|
|
assert backend.get_attachment(existing.attachment_id)["refcount"] == 1
|
|
assert backend.get_attachment(new.attachment_id) is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mime_type", "kind"),
|
|
[("text/plain", "image"), ("image/png", "preview")],
|
|
)
|
|
def test_tool_cas_mime_and_kind_conflict_rolls_back(
|
|
storage_backend: Any,
|
|
mime_type: str,
|
|
kind: str,
|
|
) -> None:
|
|
backend = storage_backend
|
|
ws_id = f"atomic-tool-cas-{kind}"
|
|
backend.register_workstream(ws_id)
|
|
attachment_id = "3" * 64
|
|
backend.save_attachment(
|
|
attachment_id,
|
|
"existing.png",
|
|
mime_type,
|
|
len(b"same-bytes"),
|
|
kind,
|
|
b"same-bytes",
|
|
"tool",
|
|
)
|
|
|
|
with pytest.raises(ConversationCommitConflictError):
|
|
_commit(backend, ws_id, [_attachment(attachment_id, b"same-bytes")])
|
|
|
|
assert backend.count_messages(ws_id) == 0
|
|
assert backend.get_attachment(attachment_id)["refcount"] == 1
|
|
|
|
|
|
def test_tool_memory_facade_preserves_typed_commit_conflict(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class _ConflictStorage:
|
|
def save_tool_message_with_attachments(self, *_args: Any, **_kwargs: Any) -> int:
|
|
raise ConversationCommitConflictError("immutable tool commit mismatch")
|
|
|
|
monkeypatch.setattr(memory, "get_storage", lambda: _ConflictStorage())
|
|
with pytest.raises(ConversationCommitConflictError, match="immutable tool commit mismatch"):
|
|
memory.save_tool_message_with_attachments(
|
|
"atomic-tool-facade-conflict",
|
|
"result",
|
|
"read_file",
|
|
"call-1",
|
|
[_attachment("4" * 64, b"payload")],
|
|
commit_key="conflicting-tool-commit",
|
|
)
|
|
|
|
|
|
def test_tool_memory_facade_returns_explicit_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class _BrokenStorage:
|
|
def save_tool_message_with_attachments(self, *_args: Any, **_kwargs: Any) -> int:
|
|
raise RuntimeError("database unavailable")
|
|
|
|
monkeypatch.setattr(memory, "get_storage", lambda: _BrokenStorage())
|
|
result = memory.save_tool_message_with_attachments(
|
|
"atomic-tool-facade",
|
|
"result",
|
|
"read_file",
|
|
"call-1",
|
|
[_attachment("4" * 64, b"payload")],
|
|
commit_key="failed-tool-commit",
|
|
)
|
|
assert result == 0
|
|
|
|
|
|
class _PostgresResult:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
scalar: int | None = None,
|
|
row: Any | None = None,
|
|
rows: list[Any] | None = None,
|
|
scalar_values: list[str] | None = None,
|
|
) -> None:
|
|
self._scalar = scalar
|
|
self._row = row
|
|
self._rows = rows or []
|
|
self._scalar_values = scalar_values or []
|
|
|
|
def scalar_one_or_none(self) -> int | None:
|
|
return self._scalar
|
|
|
|
def fetchone(self) -> Any | None:
|
|
return self._row
|
|
|
|
def fetchall(self) -> list[Any]:
|
|
return self._rows
|
|
|
|
def scalars(self) -> list[str]:
|
|
return self._scalar_values
|
|
|
|
|
|
class _PostgresConnection:
|
|
def __init__(self, results: list[_PostgresResult | BaseException]) -> None:
|
|
self._results = results
|
|
self.statements: list[Any] = []
|
|
self.commits = 0
|
|
self.rollbacks = 0
|
|
|
|
def execute(self, statement: Any, *_args: Any, **_kwargs: Any) -> _PostgresResult:
|
|
self.statements.append(statement)
|
|
if not self._results:
|
|
raise AssertionError("unexpected PostgreSQL statement")
|
|
result = self._results.pop(0)
|
|
if isinstance(result, BaseException):
|
|
raise result
|
|
return result
|
|
|
|
def commit(self) -> None:
|
|
self.commits += 1
|
|
|
|
def rollback(self) -> None:
|
|
self.rollbacks += 1
|
|
|
|
|
|
def _blob_row(attachment: AttachmentWrite) -> Any:
|
|
return SimpleNamespace(
|
|
_mapping={
|
|
"attachment_id": attachment.attachment_id,
|
|
"mime_type": attachment.mime_type,
|
|
"size_bytes": attachment.size_bytes,
|
|
"kind": attachment.kind,
|
|
"content": attachment.content,
|
|
}
|
|
)
|
|
|
|
|
|
def test_postgresql_tool_insert_uses_one_conflict_safe_transaction() -> None:
|
|
"""The blob insert reports what it wrote; freshly written bytes aren't re-read.
|
|
|
|
Rationale for the schedule change: the blob insert now carries
|
|
``RETURNING attachment_id``, so ids this transaction actually wrote need no
|
|
content verification — it just supplied those bytes. Re-reading them would
|
|
pull the whole tool payload back out of the database while the parent row
|
|
lock is held. Only conflicted (pre-existing) ids are verified, covered by
|
|
``test_tool_cas_mime_and_kind_conflict_rolls_back``.
|
|
"""
|
|
attachment = _attachment("5" * 64, b"postgres-tool")
|
|
conn = _PostgresConnection(
|
|
[
|
|
_PostgresResult(row=("postgres-tool-insert",)),
|
|
_PostgresResult(scalar=51),
|
|
_PostgresResult(scalar_values=[attachment.attachment_id]),
|
|
_PostgresResult(scalar_values=[attachment.attachment_id]),
|
|
_PostgresResult(),
|
|
]
|
|
)
|
|
backend = PostgreSQLBackend.__new__(PostgreSQLBackend)
|
|
backend._conn = lambda: nullcontext(conn) # type: ignore[method-assign]
|
|
|
|
assert _commit(backend, "postgres-tool-insert", [attachment], is_error=True) == 51
|
|
|
|
compiled = [statement.compile(dialect=postgresql.dialect()) for statement in conn.statements]
|
|
sql = [str(statement) for statement in compiled]
|
|
assert "SELECT workstreams.ws_id" in sql[0] and "FOR UPDATE" in sql[0]
|
|
assert "INSERT INTO conversations" in sql[1] and "ON CONFLICT" in sql[1]
|
|
assert "WHERE commit_key IS NOT NULL" in sql[1]
|
|
assert compiled[1].params["role"] == "tool"
|
|
assert compiled[1].params["tool_name"] == "read_file"
|
|
assert compiled[1].params["tool_call_id"] == "call-image"
|
|
assert compiled[1].params["is_error"] is True
|
|
assert "INSERT INTO workstream_attachments" in sql[2] and "ON CONFLICT" in sql[2]
|
|
assert "RETURNING workstream_attachments.attachment_id" in sql[2]
|
|
assert any(
|
|
key.startswith("origin") and value == "tool" for key, value in compiled[2].params.items()
|
|
)
|
|
assert "UPDATE workstream_attachments" in sql[3]
|
|
assert "UPDATE workstreams" in sql[4]
|
|
assert all("SELECT workstream_attachments.attachment_id" not in item for item in sql)
|
|
assert conn.commits == 1
|
|
assert conn.rollbacks == 0
|
|
assert conn._results == []
|
|
|
|
|
|
def test_postgresql_identical_retry_emits_no_refcount_update() -> None:
|
|
attachment = _attachment("6" * 64, b"postgres-retry")
|
|
existing = SimpleNamespace(
|
|
_mapping={
|
|
"id": 61,
|
|
"role": "tool",
|
|
"content": "Image file: result.png",
|
|
"tool_name": "read_file",
|
|
"tool_call_id": "call-image",
|
|
"provider_data": None,
|
|
"tool_calls": None,
|
|
"_source": None,
|
|
"event_id": 29,
|
|
"is_error": False,
|
|
"attachments": json.dumps([attachment.attachment_id]),
|
|
"meta": '{"effect_status":"succeeded"}',
|
|
"commit_key": "one-tool-result",
|
|
}
|
|
)
|
|
conn = _PostgresConnection(
|
|
[
|
|
_PostgresResult(row=("postgres-tool-retry",)),
|
|
_PostgresResult(scalar=None),
|
|
_PostgresResult(row=existing),
|
|
_PostgresResult(rows=[_blob_row(attachment)]),
|
|
]
|
|
)
|
|
backend = PostgreSQLBackend.__new__(PostgreSQLBackend)
|
|
backend._conn = lambda: nullcontext(conn) # type: ignore[method-assign]
|
|
|
|
assert _commit(backend, "postgres-tool-retry", [attachment]) == 61
|
|
|
|
sql = [str(statement.compile(dialect=postgresql.dialect())) for statement in conn.statements]
|
|
assert len(sql) == 4
|
|
assert all("UPDATE workstream_attachments" not in statement for statement in sql)
|
|
assert conn.commits == 1
|
|
assert conn.rollbacks == 0
|
|
assert conn._results == []
|
|
|
|
|
|
def test_postgresql_partial_failure_rolls_back_the_transaction() -> None:
|
|
attachment = _attachment("7" * 64, b"postgres-rollback")
|
|
conn = _PostgresConnection(
|
|
[
|
|
_PostgresResult(row=("postgres-tool-rollback",)),
|
|
_PostgresResult(scalar=71),
|
|
_PostgresResult(scalar_values=[attachment.attachment_id]),
|
|
RuntimeError("injected PostgreSQL retain failure"),
|
|
]
|
|
)
|
|
backend = PostgreSQLBackend.__new__(PostgreSQLBackend)
|
|
backend._conn = lambda: nullcontext(conn) # type: ignore[method-assign]
|
|
|
|
with pytest.raises(RuntimeError, match="retain failure"):
|
|
_commit(backend, "postgres-tool-rollback", [attachment])
|
|
|
|
sql = [str(statement.compile(dialect=postgresql.dialect())) for statement in conn.statements]
|
|
assert "SELECT workstreams.ws_id" in sql[0] and "FOR UPDATE" in sql[0]
|
|
assert "INSERT INTO conversations" in sql[1]
|
|
assert "INSERT INTO workstream_attachments" in sql[2]
|
|
assert "UPDATE workstream_attachments" in sql[3]
|
|
assert conn.commits == 0
|
|
assert conn.rollbacks == 1
|
|
assert conn._results == []
|