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
388 lines
15 KiB
Python
388 lines
15 KiB
Python
"""Tail-truncation ordering and attachment-GC regression coverage."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
from contextlib import nullcontext
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.dialects import postgresql
|
|
|
|
from tests._storage_fakes import (
|
|
ScriptedPostgresConnection,
|
|
ScriptedPostgresResult,
|
|
make_attachment,
|
|
save_keyed,
|
|
)
|
|
from turnstone.core.storage._postgresql import PostgreSQLBackend
|
|
|
|
if TYPE_CHECKING:
|
|
from turnstone.core.storage import AttachmentWrite
|
|
|
|
|
|
def _attachment(attachment_id: str, content: bytes) -> AttachmentWrite:
|
|
return make_attachment(attachment_id, content)
|
|
|
|
|
|
def _save_keyed(
|
|
backend: Any,
|
|
ws_id: str,
|
|
kind: str,
|
|
shared: AttachmentWrite,
|
|
added: AttachmentWrite,
|
|
) -> int:
|
|
return save_keyed(
|
|
backend,
|
|
ws_id,
|
|
kind,
|
|
content="accepted after truncation",
|
|
commit_key=f"truncate-race-{kind}",
|
|
attachments=[shared, added, added],
|
|
tool_call_id="call-truncate-race",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["keep_count", "remove_count"])
|
|
@pytest.mark.parametrize("kind", ["plain", "user", "tool"])
|
|
def test_storage_tail_truncation_orders_before_keyed_commit_and_releases_exact_refs(
|
|
storage_backend: Any,
|
|
kind: str,
|
|
operation: str,
|
|
) -> None:
|
|
"""An ACKed keyed commit cannot disappear behind tail truncation.
|
|
|
|
Pause legacy truncation after its cutoff or strict truncation after its
|
|
in-transaction total. The transaction must already own the SQLite writer
|
|
reservation or PostgreSQL parent lock, so a later keyed writer cannot
|
|
finish until the doomed row and its exact attachment references have been
|
|
removed and committed.
|
|
"""
|
|
|
|
backend = storage_backend
|
|
ws_id = f"truncate-keyed-race-{operation}-{kind}"
|
|
shared = _attachment("a" * 64, b"shared")
|
|
doomed = _attachment("b" * 64, b"doomed")
|
|
added = _attachment("c" * 64, b"added")
|
|
backend.register_workstream(ws_id)
|
|
backend.save_tool_message_with_attachments(
|
|
ws_id,
|
|
"keep",
|
|
"read_file",
|
|
"call-keep",
|
|
[shared],
|
|
commit_key="truncate-keep",
|
|
)
|
|
backend.save_tool_message_with_attachments(
|
|
ws_id,
|
|
"remove",
|
|
"read_file",
|
|
"call-remove",
|
|
[shared, doomed, doomed],
|
|
commit_key="truncate-remove",
|
|
)
|
|
assert backend.get_attachment(shared.attachment_id)["refcount"] == 2
|
|
assert backend.get_attachment(doomed.attachment_id)["refcount"] == 2
|
|
|
|
# Exercise SQLite's supported no-FTS path so this test also catches a
|
|
# deferred read-to-write upgrade; FTS happens to acquire a writer lock as a
|
|
# side effect, but it is not the truncation transaction's ordering contract.
|
|
if backend.__class__.__name__ == "SQLiteBackend":
|
|
backend._fts5_available = False
|
|
|
|
boundary_selected = threading.Event()
|
|
save_attempted = threading.Event()
|
|
save_done = threading.Event()
|
|
outcomes: dict[str, Any] = {}
|
|
errors: list[BaseException] = []
|
|
|
|
def _is_pause_select(statement: str) -> bool:
|
|
sql = " ".join(statement.lower().split())
|
|
if operation == "keep_count":
|
|
return (
|
|
sql.startswith("select conversations.id")
|
|
and "order by conversations.id" in sql
|
|
and " offset " in sql
|
|
)
|
|
return sql.startswith("select count(*)") and "from conversations" in sql
|
|
|
|
def _before_cursor_execute(
|
|
_conn: Any,
|
|
_cursor: Any,
|
|
_statement: str,
|
|
_parameters: Any,
|
|
_context: Any,
|
|
_executemany: bool,
|
|
) -> None:
|
|
if threading.current_thread().name == "keyed-save-after-truncation":
|
|
save_attempted.set()
|
|
|
|
def _after_cursor_execute(
|
|
_conn: Any,
|
|
_cursor: Any,
|
|
statement: str,
|
|
_parameters: Any,
|
|
_context: Any,
|
|
_executemany: bool,
|
|
) -> None:
|
|
if (
|
|
threading.current_thread().name == "paused-tail-truncation"
|
|
and not boundary_selected.is_set()
|
|
and _is_pause_select(statement)
|
|
):
|
|
boundary_selected.set()
|
|
if not save_attempted.wait(timeout=10):
|
|
raise AssertionError("keyed save never reached its first database operation")
|
|
# An unsafe implementation lets the save commit in this window;
|
|
# the later id-range DELETE then removes an already-ACKed row.
|
|
outcomes["save_crossed_boundary"] = save_done.wait(timeout=0.5)
|
|
|
|
def _truncate() -> None:
|
|
try:
|
|
if operation == "keep_count":
|
|
outcomes["deleted"] = backend.delete_messages_after(ws_id, 1)
|
|
else:
|
|
outcomes["deleted"] = backend.truncate_messages_tail(ws_id, 1)
|
|
except BaseException as exc: # pragma: no cover - surfaced below
|
|
errors.append(exc)
|
|
|
|
def _save() -> None:
|
|
try:
|
|
outcomes["saved_id"] = _save_keyed(backend, ws_id, kind, shared, added)
|
|
except BaseException as exc: # pragma: no cover - surfaced below
|
|
errors.append(exc)
|
|
finally:
|
|
save_done.set()
|
|
|
|
sa.event.listen(backend._engine, "before_cursor_execute", _before_cursor_execute)
|
|
sa.event.listen(backend._engine, "after_cursor_execute", _after_cursor_execute)
|
|
truncate_thread = threading.Thread(target=_truncate, name="paused-tail-truncation")
|
|
save_thread = threading.Thread(target=_save, name="keyed-save-after-truncation")
|
|
try:
|
|
truncate_thread.start()
|
|
assert boundary_selected.wait(timeout=10), "truncation never reached its read boundary"
|
|
save_thread.start()
|
|
truncate_thread.join(timeout=10)
|
|
save_thread.join(timeout=10)
|
|
finally:
|
|
sa.event.remove(backend._engine, "before_cursor_execute", _before_cursor_execute)
|
|
sa.event.remove(backend._engine, "after_cursor_execute", _after_cursor_execute)
|
|
|
|
assert not truncate_thread.is_alive()
|
|
assert not save_thread.is_alive()
|
|
assert errors == []
|
|
assert outcomes["save_crossed_boundary"] is False
|
|
assert outcomes["deleted"] == 1
|
|
assert isinstance(outcomes["saved_id"], int)
|
|
|
|
messages = backend.load_messages(ws_id, repair=False)
|
|
assert [message["_commit_key"] for message in messages] == [
|
|
"truncate-keep",
|
|
f"truncate-race-{kind}",
|
|
]
|
|
assert backend.count_messages(ws_id) == 2
|
|
expected_shared_refs = 1 if kind == "plain" else 2
|
|
assert backend.get_attachment(shared.attachment_id)["refcount"] == expected_shared_refs
|
|
assert backend.get_attachment(doomed.attachment_id) is None
|
|
if kind == "plain":
|
|
assert backend.get_attachment(added.attachment_id) is None
|
|
else:
|
|
assert backend.get_attachment(added.attachment_id)["refcount"] == 2
|
|
assert backend.list_orphan_conversations() == []
|
|
|
|
|
|
def test_atomic_tail_truncation_clamps_at_latest_compaction_marker(
|
|
storage_backend: Any,
|
|
) -> None:
|
|
backend = storage_backend
|
|
ws_id = "truncate-compaction-floor"
|
|
backend.register_workstream(ws_id)
|
|
for index in range(3):
|
|
backend.save_message(ws_id, "user", f"prefix-{index}")
|
|
watermark = backend.get_compaction_watermark(ws_id, 0)
|
|
backend.save_message(
|
|
ws_id,
|
|
"assistant",
|
|
"summary",
|
|
source="compaction",
|
|
meta=json.dumps({"watermark": watermark}),
|
|
)
|
|
backend.save_message(ws_id, "user", "tail-1")
|
|
backend.save_message(ws_id, "assistant", "tail-2")
|
|
assert backend.count_messages(ws_id) == 6
|
|
assert backend.get_compaction_floor(ws_id) == 4
|
|
|
|
assert backend.truncate_messages_tail(ws_id, 1) == 1
|
|
assert backend.count_messages(ws_id) == 5
|
|
assert backend.truncate_messages_tail(ws_id, 100) == 1
|
|
assert backend.truncate_messages_tail(ws_id, 1) == 0
|
|
|
|
rows = backend.load_messages(
|
|
ws_id,
|
|
repair=False,
|
|
include_compaction=True,
|
|
)
|
|
assert [row["content"] for row in rows] == [
|
|
"prefix-0",
|
|
"prefix-1",
|
|
"prefix-2",
|
|
"summary",
|
|
]
|
|
assert backend.get_compaction_floor(ws_id) == 4
|
|
|
|
|
|
def test_atomic_tail_truncation_requires_parent_and_valid_count(
|
|
storage_backend: Any,
|
|
) -> None:
|
|
backend = storage_backend
|
|
backend.register_workstream("truncate-strict-input")
|
|
backend.save_message("truncate-strict-input", "user", "keep")
|
|
|
|
with pytest.raises(ValueError, match="non-negative"):
|
|
backend.truncate_messages_tail("truncate-strict-input", -1)
|
|
assert backend.truncate_messages_tail("truncate-strict-input", 0) == 0
|
|
assert backend.count_messages("truncate-strict-input") == 1
|
|
|
|
# Legacy unkeyed storage can contain an orphan row. The strict operation
|
|
# must refuse it rather than mutating history without a durable lock target.
|
|
backend.save_message("truncate-orphan", "assistant", "orphan")
|
|
with pytest.raises(RuntimeError, match="workstream no longer exists"):
|
|
backend.truncate_messages_tail("truncate-orphan", 1)
|
|
assert backend.count_messages("truncate-orphan") == 1
|
|
|
|
|
|
def test_atomic_tail_truncation_rolls_back_rows_and_refs_on_release_failure(
|
|
storage_backend: Any,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
backend = storage_backend
|
|
ws_id = "truncate-release-rollback"
|
|
shared = _attachment("f" * 64, b"shared-rollback")
|
|
doomed = _attachment("0" * 64, b"doomed-rollback")
|
|
backend.register_workstream(ws_id)
|
|
backend.save_tool_message_with_attachments(
|
|
ws_id,
|
|
"keep",
|
|
"read_file",
|
|
"call-rollback-keep",
|
|
[shared],
|
|
commit_key="rollback-keep",
|
|
)
|
|
backend.save_tool_message_with_attachments(
|
|
ws_id,
|
|
"remove",
|
|
"read_file",
|
|
"call-rollback-remove",
|
|
[shared, doomed, doomed],
|
|
commit_key="rollback-remove",
|
|
)
|
|
|
|
# The tail-delete body (and its release call) lives in the shared _utils
|
|
# core since the round-4 dedup — patch where the call resolves.
|
|
from turnstone.core.storage import _utils as utils_module
|
|
|
|
real_release = utils_module.release_attachment_refs
|
|
|
|
def _release_then_fail(conn: Any, attachment_ids: list[str]) -> None:
|
|
real_release(conn, attachment_ids)
|
|
raise RuntimeError("injected truncation release failure")
|
|
|
|
monkeypatch.setattr(utils_module, "release_attachment_refs", _release_then_fail)
|
|
with pytest.raises(RuntimeError, match="injected truncation release failure"):
|
|
backend.truncate_messages_tail(ws_id, 1)
|
|
|
|
assert backend.count_messages(ws_id) == 2
|
|
assert backend.get_attachment(shared.attachment_id)["refcount"] == 2
|
|
assert backend.get_attachment(doomed.attachment_id)["refcount"] == 2
|
|
|
|
|
|
def test_postgresql_tail_truncation_locks_parent_and_releases_returned_refs() -> None:
|
|
first = "d" * 64
|
|
repeated = "e" * 64
|
|
conn = ScriptedPostgresConnection(
|
|
[
|
|
ScriptedPostgresResult(row=("postgres-truncate",)),
|
|
ScriptedPostgresResult(row=(42,)),
|
|
ScriptedPostgresResult(rows=[(json.dumps([first, repeated, repeated]),), (None,)]),
|
|
ScriptedPostgresResult(),
|
|
ScriptedPostgresResult(),
|
|
]
|
|
)
|
|
backend = PostgreSQLBackend.__new__(PostgreSQLBackend)
|
|
backend._conn = cast("Any", lambda: nullcontext(conn)) # type: ignore[method-assign]
|
|
|
|
assert backend.delete_messages_after("postgres-truncate", 3) == 2
|
|
|
|
dialect = postgresql.dialect() # type: ignore[no-untyped-call]
|
|
compiled = [statement.compile(dialect=dialect) for statement in conn.statements]
|
|
sql = [" ".join(str(statement).split()) for statement in compiled]
|
|
assert "SELECT workstreams.ws_id" in sql[0] and "FOR UPDATE" in sql[0]
|
|
assert "SELECT conversations.id" in sql[1] and "OFFSET" in sql[1]
|
|
assert "DELETE FROM conversations" in sql[2]
|
|
assert "RETURNING conversations.attachments" in sql[2]
|
|
assert all("SELECT conversations.attachments" not in statement for statement in sql)
|
|
assert "UPDATE workstream_attachments" in sql[3]
|
|
assert "DELETE FROM workstream_attachments" in sql[4]
|
|
assert conn.commits == 1
|
|
assert conn.rollbacks == 0
|
|
assert conn._results == []
|
|
|
|
|
|
def test_postgresql_atomic_tail_truncation_computes_floor_under_parent_lock() -> None:
|
|
first = "1" * 64
|
|
repeated = "2" * 64
|
|
conn = ScriptedPostgresConnection(
|
|
[
|
|
ScriptedPostgresResult(row=("postgres-atomic-truncate",)),
|
|
ScriptedPostgresResult(scalar_value=6),
|
|
ScriptedPostgresResult(scalar_value=14),
|
|
ScriptedPostgresResult(scalar_value=4),
|
|
ScriptedPostgresResult(row=(15,)),
|
|
ScriptedPostgresResult(rows=[(json.dumps([first, repeated, repeated]),), (None,)]),
|
|
ScriptedPostgresResult(),
|
|
ScriptedPostgresResult(),
|
|
]
|
|
)
|
|
backend = PostgreSQLBackend.__new__(PostgreSQLBackend)
|
|
backend._conn = cast("Any", lambda: nullcontext(conn)) # type: ignore[method-assign]
|
|
|
|
assert backend.truncate_messages_tail("postgres-atomic-truncate", 2) == 2
|
|
|
|
dialect = postgresql.dialect() # type: ignore[no-untyped-call]
|
|
assert str(conn.statements[0]).startswith("SET LOCAL lock_timeout")
|
|
compiled = [statement.compile(dialect=dialect) for statement in conn.statements[1:]]
|
|
sql = [" ".join(str(statement).split()) for statement in compiled]
|
|
assert "SELECT workstreams.ws_id" in sql[0] and "FOR UPDATE" in sql[0]
|
|
assert "count(*)" in sql[1] and "FROM conversations" in sql[1]
|
|
assert "max(conversations.id)" in sql[2]
|
|
assert "count(*)" in sql[3] and "conversations.id <=" in sql[3]
|
|
assert "SELECT conversations.id" in sql[4] and "OFFSET" in sql[4]
|
|
assert 4 in compiled[4].params.values()
|
|
assert "DELETE FROM conversations" in sql[5]
|
|
assert "RETURNING conversations.attachments" in sql[5]
|
|
assert all("SELECT conversations.attachments" not in statement for statement in sql)
|
|
assert "UPDATE workstream_attachments" in sql[6]
|
|
assert "DELETE FROM workstream_attachments" in sql[7]
|
|
assert conn.commits == 1
|
|
assert conn.rollbacks == 0
|
|
assert conn._results == []
|
|
|
|
|
|
def test_postgresql_atomic_tail_truncation_rolls_back_when_parent_is_missing() -> None:
|
|
conn = ScriptedPostgresConnection([ScriptedPostgresResult(row=None)])
|
|
backend = PostgreSQLBackend.__new__(PostgreSQLBackend)
|
|
backend._conn = cast("Any", lambda: nullcontext(conn)) # type: ignore[method-assign]
|
|
|
|
with pytest.raises(RuntimeError, match="workstream no longer exists"):
|
|
backend.truncate_messages_tail("postgres-missing", 1)
|
|
|
|
dialect = postgresql.dialect() # type: ignore[no-untyped-call]
|
|
assert str(conn.statements[0]).startswith("SET LOCAL lock_timeout")
|
|
sql = " ".join(str(conn.statements[1].compile(dialect=dialect)).split())
|
|
assert "SELECT workstreams.ws_id" in sql and "FOR UPDATE" in sql
|
|
assert conn.commits == 0
|
|
assert conn.rollbacks == 1
|
|
assert conn._results == []
|