[bug] DbRunEventStore: delete_by_run lowers the thread's max-seq watermark, so later puts reissue already-used seq values and become invisible to after_seq cursors · Issue #6471 · bytedance/deer-flow · GitHub
You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[bug] DbRunEventStore: delete_by_run lowers the thread's max-seq watermark, so later puts reissue already-used seq values and become invisible to after_seq cursors #6471
After delete_by_run() deletes one run's events, DbRunEventStore computes new seq values from the max over the remaining rows, so later puts reissue seq numbers that were already handed out and the new events become permanently invisible to after_seq cursor pagination — while MemoryRunEventStore and JsonlRunEventStore keep the seq strictly increasing for the identical operation sequence.
Affected area(s)
Backend API (gateway / endpoints / SSE)
What happened?
DbRunEventStore assigns each new event seq = (max seq over remaining rows) + 1 — put at backend/packages/harness/deerflow/runtime/events/store/db.py:204, put_batch at db.py:232, put_if_absent at db.py:303. delete_by_run() (db.py:542-566) deletes rows without recording any per-thread high-water mark, so the max drops and the next write can:
reissue a seq value that was already assigned to a (now deleted) event — the assigned sequence 1, 2, 2 is not strictly increasing; and
store the new event at a seq that client feed cursors have already advanced past, making it invisible: list_messages(after_seq=2) returns [] for an event stored at seq 2.
The memory and JSONL backends keep a per-thread counter that delete_by_run never lowers (memory.py:34-38, counter untouched by delete_by_run at memory.py:230-243; jsonl.py:128-130, counter only popped by delete_by_thread at jsonl.py:466), so the same operation sequence yields seq 3 and the message is returned. The three implementations of one interface disagree on a documented, tested public method.
Reachability caveat, stated up front: I could not find an in-tree production caller of the run-event store's delete_by_run() today — the gateway wires delete_by_thread() (app/gateway/routers/threads.py:813), and the other delete_by_run references are different classes (FeedbackRepository.delete_by_run in backend/packages/harness/deerflow/persistence/feedback/sql.py:175). So this is a store-contract defect reachable by direct RunEventStore consumers (SDK/embedded use, future run-delete routes), not currently a live HTTP data-loss path.
Expected behavior
Per the RunEventStore ABC contract, backend/packages/harness/deerflow/runtime/events/store/base.py:49-57:
All implementations must guarantee:
put() events are retrievable in subsequent queries
seq is strictly increasing within the same thread
db.py's own put docstring (db.py:188-196) likewise says it "assign[s] a monotonic seq".
So after put(r1) → seq 1, put(r2) → seq 2, delete_by_run(t1, r2), the next put(r3) on the same thread must receive a seq greater than every seq previously handed out on that thread (3, as memory and jsonl do), and list_messages(after_seq=2) must return r3's message. The exclusive before_seq/after_seq cursors (base.py:112-119, exposed over HTTP at app/gateway/routers/runs.py:124) only stay sound if newly assigned seqs exceed every seq previously handed to a client while the thread is live.
Steps to reproduce
Save as repro.py, then cd backend && uv run python repro.py. It runs the same operation sequence against all three RunEventStore backends (sqlite for the db one).
Verbatim output of uv run python repro.py (identical at commit 5872d07 and at current maina4427625):
[memory] r1 seq=1 r2 seq=2
[memory] after delete_by_run(r2), r3 first event seq=3 (previously assigned max seq was 2)
[memory] list_messages(after_seq=2) -> [3] (r3's message must appear)[jsonl ] r1 seq=1 r2 seq=2[jsonl ] after delete_by_run(r2), r3 first event seq=3 (previously assigned max seq was 2)[jsonl ] list_messages(after_seq=2) -> [3] (r3's message must appear)
[db ] r1 seq=1 r2 seq=2
[db ] after delete_by_run(r2), r3 first event seq=2 (previously assigned max seq was 2)
[db ] list_messages(after_seq=2) -> [] (r3's message must appear)
The db block shows both symptoms: r3's first event reissues seq 2 (the assigned sequence 1, 2, 2 is not strictly increasing), and list_messages(after_seq=2) returns [], so r3's message is invisible to any client whose cursor already advanced past seq 2.
How are you running DeerFlow?
Other
Operating system
macOS
Platform details
arm64, zsh
Python version
Python 3.12.14
Node.js version
N/A (Python-only repro against the backend harness package)
pnpm version
N/A
uv version
0.12.8
Git state
branch: main
commit: 5872d07 — also reproduced on current maina442762; backend/packages/harness/deerflow/runtime/events/store/ is unchanged between the two commits.
Support bundle summary
Not applicable — deterministic store-logic bug, no setup/config/sandbox involvement.
Additional context
Root cause:DbRunEventStore derives each new seq from the stored rows (seq = (max_seq or 0) + 1 at db.py:204, db.py:232, db.py:303), and delete_by_run (db.py:542-566) removes rows without recording the thread's high-water mark, so the max drops. The sibling backends never lower their counters on a single-run delete: MemoryRunEventStore._seq_counters (memory.py:34-38, untouched by delete_by_run at memory.py:230-243) and JsonlRunEventStore._next_seq (jsonl.py:128-130, counter popped only by delete_by_thread at jsonl.py:466, which removes all rows and documents restarting at seq 1).
The shared contract suite passes because test_delete_by_run in backend/tests/test_run_event_store.py only puts, deletes, and reads — it never puts again after the delete, so this divergence is untested.
A possible fix direction: keep a per-thread high-water mark that deletions never lower — e.g. delete_by_run/delete_by_thread record the thread's current max seq into a durable watermark before deleting rows, and writers assign max(max_seq_in_table, watermark) + 1 — plus a contract test parametrized over backends (put r1/r2 → delete_by_run(r2) → put r3, assert r3.seq > r2.seq and that list_messages(after_seq=r2.seq) returns r3's message), which fails on db today and passes on memory/jsonl.
Related: #5530 fixed the deletion mutation fence in this same file (a concurrency race between delete and admitted writers). This report is a different, deterministic defect — the seq watermark itself drops after a delete even with no concurrency.
Happy to be assigned and open a PR with the watermark approach and the contract test.
Reproduced this. Root cause: backend/packages/harness/deerflow/runtime/events/store/db.py:204 — put/put_batch (db.py:232) and put_if_absent (db.py:303) derive the next seq from max(seq) over the surviving rows, and delete_by_run (db.py:542) removes rows without recording the thread's high-water mark, so the max drops and the next put reissues a seq a client cursor already consumed.
Minimal reproduction:
cd backend && uv run python repro.py
Observed output:
[db ] r1 seq=1 r2 seq=2
[db ] after delete_by_run(r2), r3 first event seq=2 (previously assigned max seq was 2)
[db ] list_messages(after_seq=2) -> [] (r3's message must appear)
Fix is ready on BlueX888:fix/prep-db-event-store-seq-reuse-after-delete-by-run (commit f94d4ed8). Keeps a per-thread seq high-water mark that delete_by_run never lowers (retired only by delete_by_thread together with the thread's rows) and routes put/put_batch/put_if_absent through one _next_seq helper, matching the per-thread counters the memory and JSONL stores keep.
Verified with: cd backend && PYTHONPATH=. uv run pytest -m 'not live' --ignore=tests/blocking_io tests/test_run_event_store.py
Happy to open the PR once you confirm the approach or assign this to me.
Activity
BlueX888 commentedon Oct 7, 2026
Reproduced this. Root cause: backend/packages/harness/deerflow/runtime/events/store/db.py:204 — put/put_batch (db.py:232) and put_if_absent (db.py:303) derive the next seq from max(seq) over the surviving rows, and delete_by_run (db.py:542) removes rows without recording the thread's high-water mark, so the max drops and the next put reissues a seq a client cursor already consumed.
Minimal reproduction:
Observed output:
Fix is ready on
BlueX888:fix/prep-db-event-store-seq-reuse-after-delete-by-run(commitf94d4ed8). Keeps a per-thread seq high-water mark that delete_by_run never lowers (retired only by delete_by_thread together with the thread's rows) and routes put/put_batch/put_if_absent through one _next_seq helper, matching the per-thread counters the memory and JSONL stores keep.Verified with:
cd backend && PYTHONPATH=. uv run pytest -m 'not live' --ignore=tests/blocking_io tests/test_run_event_store.pyHappy to open the PR once you confirm the approach or assign this to me.