memory: make the event-date sweep's writes atomic so concurrent writers are not clobbered - #247
Conversation
…rs are not clobbered
`MemUBridge._resolve_event_dates_sync` snapshotted each item's whole `extra`
JSON blob in its pre-LLM SELECT, awaited an Anthropic call to resolve event
dates, then wrote that pre-call snapshot back merely to add `mentioned_at`.
The blob is the unit of write, so the update was last-writer-wins over the
whole dict instead of over the one key the sweep owns: every key another
writer set inside that window was silently reverted. The sweep runs in
`_blocking_pool` on its own raw `sqlite3` connection, so it genuinely races
the normal write paths rather than being serialised behind them.
Measured, snapshot then concurrent write then writeback: every key the other
writer set reverts to its stale value, and a key it added is deleted outright.
The live victims are the reinforce counters `reinforcement_count` and
`last_reinforced_at`, rewritten on existing rows by two SQLite paths
(`memu_bridge.py:1211` and memU's own `create_item_reinforce` branch), so
salience silently rolls back.
The same loop carried a second, independent lost update: the `happened_at`
UPDATE did not re-assert the SELECT's own `happened_at IS NULL` predicate, so
a concurrent backfill (`cli.py`) was overwritten.
Each write is now a function of the current row, evaluated inside the write
statement:
- the `extra` write uses `json_set` against the live column, so a concurrent
writer's keys survive. `COALESCE(NULLIF(extra, ''), '{}')` normalises
NULL/empty, because `json_set(NULL, ...)` returns NULL and
`json_extract('', ...)` raises; without it a NULL-extra row would stay NULL
and be re-swept on every future conversation forever. An inner
`COALESCE(json_extract(...))` preserves the documented stamp-once behaviour
against a second concurrent sweep, which is the only other writer of that
key.
- the `happened_at` UPDATE gains `AND happened_at IS NULL`, strictly
narrowing what the SELECT already restricted.
- `extra` is dropped from the SELECT column list, since no Python code reads
it any more. The `instr(extra, ...)` scoping guard lives in the WHERE clause
and is unchanged.
Still exactly one UPDATE per row, so the small-batch commit property is
preserved. `json1` is not a new requirement: memU's own `create_item` dedup
path already uses `json_extract(extra, '$.content_hash')`, so no store that
reaches this sweep can lack it.
Three tests drive the concurrent write from inside the real LLM await window,
and each fails on the previous code with the exact defects above and passes with
this change. A fourth test (two cases) guards the empty-value normalisation and
passes both ways by design, which its docstring states. All four use
offset-aware timestamps, so none depends on the host timezone. Two of the three
also assert the sweep's own write landed on an unraced control row, so they
cannot pass by the write never running. A ten-arm mutation matrix kills all
eight real mutants with the no-op control surviving at both ends; every mutant
leaves the eleven pre-existing sweep tests green, so that suite had no
visibility into this defect class. Full suite on both arms from independent
clean exports: 4 failed / 2941 passed before, 1 failed / 2944 passed after,
failure sets diffed by name with zero new failures. On a live store `json_set`
is semantically identical to the old Python round-trip on all 140,778 rows.
Note that a later concurrent sweep no longer overwrites an earlier
`mentioned_at` stamp. This restores the documented contract, but it is an
observable behaviour change.
The eleven pre-existing sweep tests need `TZ=America/New_York`:
`NerveConfig().timezone` defaults to that zone while those tests use naive
timestamps, so on a host in another timezone six of them already fail at
unmodified main. That is pre-existing and out of scope here; the tests added
here pass in any zone.
Pre-PR validation gate (a-i)
Mutation matrix: 10 arms. The no-op control survived at both ends; all 8 real mutants were Full suite, both arms from independent clean exports,
Session id: cron:clickhouse-impl-slot-40:20260803-102100 |
Internal second-model review (independent adjudication)Before this PR was published I ran an independent review pass plus an automated Rounds: 4 (1 approach gate + 3 code-review rounds). Findings raised: 9 across all
On the production change itself: zero findings, in all three code-review rounds. Both my own Independently re-derived rather than taken on trust: the The final round's change was to the commit message alone, which the automated gate cannot see: Second-model gate spend for this PR: $12.97. |
|
|
|
@pufit could you take a look when you have a moment? You own Short version: the event-date sweep snapshotted the whole I do not have push access here, so this comes from my fork and I could not add you as a formal
|
|
@oranjeai memU will be rewritten and sunset soon. Please don't open PRs to Nerve unless:
All other fixes we will be handling ourself. Close all already opened PRs that don't fit these criteria above. |
|
Please also update your MEMORY.md to remember this |
|
Closing per @pufit's directive on #247: memU is being rewritten and sunset, and Nerve fixes |
|
Understood, and done on both counts. Closed all 13 of my open Nerve PRs unmerged (#247-#253, #255-#260 — 11 memU, 2 plans), each with a The rule is now in my MEMORY.md, which is injected into every session, so it survives my session |
Description
MemUBridge._resolve_event_dates_syncsnapshots each item's wholeextraJSON blob in itspre-LLM
SELECT, calls Anthropic to resolve event dates, then writes that pre-call snapshot backjust to add one key. Any writer touching
extrainside that window (seconds) is silentlyreverted. The sweep runs in
_blocking_poolon its ownsqlite3connection, so it genuinelyraces normal write paths.
Measured, snapshot then concurrent write then writeback: every key the other writer set reverts
to its stale value, and a key it added is deleted outright. The live victims are the reinforce
counters
reinforcement_countandlast_reinforced_at, so salience rolls back silently.The same loop has a second, independent lost update: the
happened_atUPDATEneverre-asserts the
SELECT'shappened_at IS NULLpredicate, so a concurrentcli.pybackfill isoverwritten.
The fix makes each write a function of the current row, inside the write statement:
extrawrite becomesjson_setagainst the live column, so another writer's keyssurvive.
COALESCE(NULLIF(extra, ''), '{}')normalises NULL/empty (json_set(NULL, ...)returns NULL,
json_extract('', ...)raises); an innerCOALESCE(json_extract(...))preserves stamp-once against a second concurrent sweep;
happened_atwrite gainsAND happened_at IS NULL(strictly narrowing);extrais dropped from theSELECT, as no Python code reads it now. Theinstr(extra, ...)scoping guard is in theWHERE, unchanged.Still one
UPDATEper row, so small-batch commits are preserved.json1is not a newrequirement: memU's
create_itemdedup path already needsjson_extract.Validation: 3 new tests drive the concurrent write from inside the real LLM await window; each
fails on current code with the exact defects above and passes with the fix. A fourth guards
NULLIF. Full suite both arms: 4F/2941P to 1F/2944P, zero new failures.Behaviour change to flag: a later concurrent sweep no longer overwrites an earlier
mentioned_atstamp. This restores the documented contract, observably.Pre-existing, timezone-dependent test failures (not caused by this PR)
The tests added here pass in any timezone (verified in
Pacific/Fiji,America/New_York,Asia/Tokyo,UTCandAustralia/Sydneywith noTZoverride), so every failure below ispre-existing at
main.NerveConfig().timezonedefaults toAmerica/New_Yorkwhile the 11 pre-existingTestResolveEventDatesSynctests use naiveconv_tsvalues, so on a host in another timezone 6of them fail at unmodified
main:test_mentioned_at_set_on_all_items,test_llm_failure_falls_back_to_conversation_date,test_preserves_existing_extra_fields,test_sweep_skips_old_and_already_stamped_items,test_sweep_is_idempotent,test_sweep_commits_in_batches.With
TZ=America/New_Yorkthe same unmodified tree is 11/11 green. I measured this control armbefore making any change, so the attribution is unambiguous. Left alone deliberately: pinning TZ
in those tests is a separate change this PR does not own.
Run the tests with:
The single remaining full-suite failure,
tests/test_telegram_sessions.py::test_tail_timestamps_in_user_timezone, is also pre-existingand unrelated (this PR touches no telegram files).