CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 1m2s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m39s
CI & Build / Build & push image (push) Successful in 39s
Sessions predicted the ids their next creates would get and wrote them into
plan bodies and reference notes before the records existed. The database
never collides; the sequence is shared by every session and user, so any
concurrent create took the guessed numbers and the references pointed at
someone else's records.
- create_records (new MCP tool) and start_planning(body=, steps=) create
their records in ONE transaction: insert, flush for the real ids, rewrite
{{ref:N}} / {{ref:milestone}} placeholders as #id "title", commit. No
prediction, no waiting, no stub records left behind when a batch fails.
Ids need not be consecutive and nothing depends on it.
- Every MCP create/update of a note, task or milestone refuses a #N sitting
just above the highest assigned id (within 50): that can only be a guess.
Refusal, not warning. Numbers far above the max (PRs, forge issues) pass.
- notes.build_note splits validation out of create_note so the batch
validates records exactly as a single create does.
- writing-plans and using-scribe say to pass steps up front and never write
an unassigned id; plugin version minted.
Integration test runs six concurrent batches and checks each resolves its
placeholders to its own records, and that a failing batch writes nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
124 lines
5.0 KiB
Python
124 lines
5.0 KiB
Python
"""Real-Postgres proof that a batch create cannot be interfered with (#4016).
|
|
|
|
What mocks cannot show: that the ids a placeholder is rewritten to are the ids
|
|
the rows actually got, that two batches running at the same time each cite
|
|
their OWN records, and that a failing batch leaves nothing behind. Those are
|
|
properties of the sequence and the transaction, so they are measured against
|
|
the real ones.
|
|
"""
|
|
import asyncio
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy import func, select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.milestone import Milestone
|
|
from scribe.models.note import Note
|
|
from scribe.models.project import Project
|
|
from scribe.services import record_refs
|
|
from scribe.services.planning import start_planning
|
|
from scribe.services.record_batch import BatchItem, create_batch
|
|
from tests.helpers import ensure_user
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_embedding():
|
|
# embed_note detaches a task that loads the embedding model; this lane is
|
|
# about ids and transactions, and a background model load would outlive
|
|
# the test's event loop.
|
|
with patch("scribe.services.notes.embed_note", MagicMock()):
|
|
yield
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def seeded():
|
|
async with async_session() as s:
|
|
owner = await ensure_user(s, "record_batch_owner")
|
|
project = Project(user_id=owner.id, title="Batch target")
|
|
s.add(project)
|
|
await s.flush()
|
|
ids = {"owner": owner.id, "pid": project.id}
|
|
await s.commit()
|
|
return ids
|
|
|
|
|
|
async def _count_in_project(pid: int) -> tuple[int, int]:
|
|
async with async_session() as s:
|
|
notes = (await s.execute(select(func.count(Note.id)).where(Note.project_id == pid))).scalar()
|
|
milestones = (await s.execute(
|
|
select(func.count(Milestone.id)).where(Milestone.project_id == pid)
|
|
)).scalar()
|
|
return notes, milestones
|
|
|
|
|
|
async def test_a_plan_and_its_steps_cite_each_others_real_ids(seeded):
|
|
owner, pid = seeded["owner"], seeded["pid"]
|
|
out = await start_planning(
|
|
owner, pid, "Ship it",
|
|
body="First {{ref:1}}, then {{ref:2}}.",
|
|
steps=[
|
|
BatchItem(title="Step one", body="Part of {{ref:milestone}}; next is {{ref:2}}."),
|
|
BatchItem(title="Step two", body="Follows {{ref:1}}."),
|
|
],
|
|
)
|
|
ms = out["milestone"]
|
|
one, two = out["steps"]
|
|
assert ms["body"] == f'First #{one["id"]} "Step one", then #{two["id"]} "Step two".'
|
|
assert one["body"] == f'Part of milestone {ms["id"]} "Ship it"; next is #{two["id"]} "Step two".'
|
|
assert two["body"] == f'Follows #{one["id"]} "Step one".'
|
|
assert one["milestone_id"] == two["milestone_id"] == ms["id"]
|
|
assert one["project_id"] == pid
|
|
|
|
# What was returned is what was stored — not a rewrite applied to the
|
|
# response alone.
|
|
async with async_session() as s:
|
|
stored = await s.get(Note, two["id"])
|
|
assert stored.body == f'Follows #{one["id"]} "Step one".'
|
|
|
|
|
|
async def test_concurrent_batches_each_cite_their_own_records(seeded):
|
|
"""The operator's scenario: several sessions creating at once. Each batch
|
|
must resolve {{ref:1}} to ITS first record, whatever the others take."""
|
|
owner, pid = seeded["owner"], seeded["pid"]
|
|
|
|
async def one_batch(tag: str):
|
|
return await create_batch(owner, [
|
|
BatchItem(title=f"{tag} first"),
|
|
BatchItem(title=f"{tag} second", body="points at {{ref:1}}"),
|
|
], project_id=pid)
|
|
|
|
results = await asyncio.gather(*(one_batch(f"session-{i}") for i in range(6)))
|
|
|
|
all_ids = [n.id for _ms, notes in results for n in notes]
|
|
assert len(all_ids) == len(set(all_ids)), "the sequence handed out an id twice"
|
|
for _ms, (first, second) in results:
|
|
assert second.body == f'points at #{first.id} "{first.title}"'
|
|
async with async_session() as s:
|
|
assert (await s.get(Note, first.id)).title == first.title
|
|
|
|
|
|
async def test_a_failing_batch_writes_nothing(seeded):
|
|
owner, pid = seeded["owner"], seeded["pid"]
|
|
before = await _count_in_project(pid)
|
|
with pytest.raises(ValueError, match="Invalid status"):
|
|
await start_planning(owner, pid, "Doomed", steps=[
|
|
BatchItem(title="fine"),
|
|
BatchItem(title="broken", status="someday"),
|
|
])
|
|
assert await _count_in_project(pid) == before, "a partial batch (or its milestone) was left behind"
|
|
|
|
|
|
async def test_a_guess_above_the_real_highest_id_is_refused(seeded):
|
|
owner, pid = seeded["owner"], seeded["pid"]
|
|
_ms, (made,) = await create_batch(owner, [BatchItem(title="anchor")], project_id=pid)
|
|
# `made` is the newest row, so anything just past it can only be a guess —
|
|
# unless a parallel test in the lane created more since, which only moves
|
|
# the ceiling up and keeps made.id itself a real id.
|
|
with pytest.raises(ValueError, match="does not exist yet"):
|
|
await record_refs.refuse_guessed_ids(f"#{made.id + record_refs.GUESS_WINDOW}")
|
|
await record_refs.refuse_guessed_ids(f"#{made.id}")
|