Documents / Issues / issue-389dc5dac58a
With `--no-daemon`, concurrent `add` invocations all receive the same id
Contradicts a documented invariant.
Class: incorrect · Severity: blocking
Flow: arch-3e305bc76ff0 · Step: id allocation under concurrency
Question: issue-96b03701503b · Frequency: unknown; requires two agents/processes writing at once, which is the stated multi-agent use case
Finding¶
With --no-daemon, concurrent add invocations all receive the same id. The read-modify-write of the counter is not serialized at the SQLite level; nothing detects the collision.
What happens today¶
OBSERVED. Six simultaneous docir --no-daemon add calls against one store ALL returned adr-0002, all exited 0, and produced six files claiming that id. Only one is visible in the index. The same race with the daemon (the default) produced adr-0002..adr-0007 correctly — the daemon's single-connection loop is what actually serializes.
Impact¶
Contradicts a documented invariant. CLAUDE.md states the counter "is what keeps parallel agents from minting the same id"; it is the daemon that does. --no-daemon is presented as an equal alternative (README:136-139) and is what the project's own test suite and CI use, so the mode where the guarantee fails is the mode most likely to be scripted.
Proposed default¶
Take the counter update inside an IMMEDIATE transaction (or UPDATE … RETURNING under a write lock), so allocation is atomic regardless of daemon mode. Failing that, document plainly that --no-daemon is single-writer-only and make the bundled profiles use id_style: random, which is already implemented and immune (as of 2026-07-26 this is what docir init writes by default — see BR-074).
Resolution¶
FIXED 2026-07-26. next_number is now a single atomic upsert (INSERT … ON CONFLICT DO UPDATE SET next_value = next_value + 1 RETURNING next_value - 1), so the increment happens under SQLite's write lock instead of in Python between two statements. busy_timeout is now set explicitly in create_index_engine so a blocked writer waits rather than erroring. Verified by isolating the change: with the retry loop and file guard temporarily removed, 16 barrier-synchronised concurrent adds x3 rounds produced 16 unique ids every time, where the old code collided at 6. Pinned by tests/modules/documents/test_concurrent_ids.py (marked slow, spawns real processes), which was confirmed to FAIL against the reverted implementation. Note the guarantee no longer depends on the daemon; the daemon remains the serializer for everything else.
Actors affected¶
- AI coding agent
- repository maintainer
- CI job
Evidence¶
src/docir/platform/persistence/repositories.py:48-56src/docir/platform/transport/server.py:20-25src/docir/modules/documents/application/services/id_generator.py:3-5ref-1509d5dbb4c3 (discovery probe log)
Migrated from the discovery gap register (GAP-009); the register itself now lives in this store.