memory: merge the semantic reinforce's extra write into the live row instead of replacing it - #257
Conversation
_semantic_sqlite_reinforce built the new extra in Python from a value read in an earlier statement, then assigned the whole column. A plain SELECT acquires no SQLite write reservation, so the transaction is still a read transaction when a second connection commits its own extra change, and the later flush emits SET extra = <that dict>, in which the other writer's keys are absent. The increment was snapshot-based for the same reason. journal_mode=WAL makes this likelier, not less: writers do not block readers, so the racing writer commits without contention instead of serialising behind us. Measured with the concurrent write landed strictly inside that window, both of its keys are gone while reinforcement_count and content_hash still look correct, so nothing appears wrong from this path's own viewpoint. The only third-party extra writer today is the event-date sweep's mentioned_at, which ClickHouse#247 converts to a json_set merge at its :2568. The live store nevertheless carries the loss already, through the cold-cache variant described below. Measured at the time of writing, of 151,228 rows 6,561 have no extra.content_hash and every one of them is a reinforced row, while the same count among never-reinforced rows is 0, so no other path can account for it. Their key sets are exactly this writeback's own ({last_reinforced_at, mentioned_at, reinforcement_count} 4,560 and {last_reinforced_at, reinforcement_count} 2,001), 11,438 reinforced rows still carry the hash, and content_hash is written only on create, so nothing re-adds it. It is still growing: 308 rows in 2026-06, 4,583 in 2026-07 and 1,670 so far in 2026-08. The next writer added would be lossy from day one on top of that, and its author has no reason to suspect this path. Both mutations are now computed server-side in one UPDATE, and the merged value is read back inside the same still-open write transaction: - json_set merges into the column's current value, so a key another writer committed survives whether it landed before or inside the window; - the increment reads json_extract on that same column in the same statement, so it is atomic rather than snapshot-based; - COALESCE(NULLIF(extra, ''), '{}') normalises NULL and empty, matching ClickHouse#247's guard; json_set on an empty string raises "malformed JSON", and a JSON-null document is normalised by json_type because json_set leaves it unchanged. A non-object extra behaves DIFFERENTLY from main and is the one shape this rewrite does not preserve: json_set returns any non-object document unchanged with rowcount 1, so the reinforce no-ops and the cache rebuild raises TypeError, whereas main read the cached dict rather than the column and so healed the row to a full object. Malformed JSON raises on both. That raise is an explicit isinstance check, not a side effect of dict() failing, because dict() SUCCEEDS on [] and on an array of pairs and would otherwise cache a value the row does not have; nothing writes a non-object extra today, since the only extra writers are two ORM assignments and one raw UPDATE, all of a dict, and at the time of writing the store holds 0 non-object rows in 153,210; - the readback is validated BEFORE the commit, and refusing it rolls back. The UPDATE sets extra AND updated_at; json_set leaves a non-object extra byte-unchanged, but updated_at is a plain bound value and would land, so validating after the commit made a REFUSED reinforce still advance the timestamp that recall and salience ordering read; - the readback CASTs extra to text, so the merged value is decoded exactly once. The column is JSON-typed, so projecting it decodes once in the driver already, and decoding that result again turns a stored JSON text scalar into a dict: the isinstance check would pass on a fabricated object, the function would report a successful reinforce, and reinforcement_count would never have moved. Assigning a str through the ORM reaches that state, exactly as an assigned None reaches the JSON-null one; - no RETURNING: the UPDATE promotes the transaction to a write transaction, so a concurrent committer gets "database is locked" and the SELECT cannot observe an interleaved write. origin/main has no RETURNING anywhere, and adding one would impose an unchecked SQLite >= 3.35 requirement; - the cache is rebuilt from the merged value, never from a reconstructed dict, which would restore the lost view one layer up. Building extra server-side also closes the cold-cache variant: every read path builds MemoryItem without extra, so after a restart the cached value is {} and a snapshot-built column wiped content_hash too. rowcount == 0 now makes a deleted row detectable, where the old "if row:" skipped the write and returned the ghost anyway, dropping the memory. That path evicts the stale cache entry and falls through to a real create. The seen_items_len resync is deliberate drift, not bookkeeping hygiene: the delegate's create adds to self.items without touching the index, so the mismatch is what forces the next rebuild and keeps the replacement row dedup-visible. Without it the next near-duplicate creates a second row. This does not claim the ghost-reinforce defect, which ClickHouse#248 owns; it uses ClickHouse#248's shape, so the two are a union in intent rather than a clean auto-merge: both rewrite this block and append to the same test-file tail, so whichever lands second needs a manual merge, and this branch omits ClickHouse#248's logger.warning on the zero-row path. ClickHouse#254 rewrites this same block as a row-seeded read-modify-write and ClickHouse#255 is the disclosed hash-arm follow-up, so both need a manual merge here too, while ClickHouse#247 merges clean; none supersedes this PR. _semantic_inmemory_reinforce is intentionally byte-unchanged: it has no DB row, so no read-to-write window, and only sqlite:/// DSNs are ever built. memU's own SQLiteMemoryItemRepo.create_item_reinforce hash arm and update_item perform the identical read-modify-write. Both are upstream memu-py code and would need a fourth wrapper with a no-double-count contract, since this arm delegates into the first, so they are disclosed rather than fixed here and a follow-up tracks both. Validation: 15 new tests, one per property, collected as 20 cases because two of them are parametrized (over four non-object shapes and three text-scalar ones); the 3 that pin concurrency land a competing write at a measured point and assert the race fired, while the other 12 pin single-writer properties and enter no window. On unmodified origin/main source 16 fail and 4 pass, with no skip: the 4 passes pin contracts the rewrite must not break (main assigns updated_at through the ORM, so it already used the same bind processor, and main's dict(extra or {}) already heals a JSON-null column, which is why the two json-null tests are regression guards on this rewrite rather than discriminators of main), while the readback test now fails on main rather than skipping, because main issues neither a readback nor a RETURNING. Full-suite failure names are identical to main (6 pre-existing, an environment timezone artifact), including a combined run of the three memory suites in one process to rule out cross-suite global contamination. A mutation matrix of 18 mutants, one per behaviour, is fully killed with an unmutated control green at both ends; among the properties they pin are the readback's write transaction, the COALESCE half of the guard, the json_type normalisation that keeps a JSON-null column from committing a value the readback decodes to None, a guard widened to json_type != 'object' that would rewrite the column, restoring the dict() laundering (killed only by the empty and pair-array cases, not by the scalar one), a commit-then-read-off-the-engine readback, which must fail rather than skip, dropping the CAST so the merged value is decoded twice, committing before the readback is validated, and a RETURNING that projects only id while the value is still read after the commit -- the last of which a check keyed on the bare RETURNING keyword skipped instead of failing.
Internal second-model review (6 rounds, 38 findings; click to expand)Every change here was reviewed by an independent model that did not write it, then adjudicated
Disagreed, with evidence
Dropped on my own measurement (#33, #38) Both are my own findings, and the mutant I built to settle #37 refutes them: the returned-count Two rounds caught figures a previous round had already "corrected", so every published number |
|
|
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-impl-slot-40:20260803-215400 |
|
Closing per @pufit's directive on #247: memU is being rewritten and sunset, and Nerve fixes |
Description
_semantic_sqlite_reinforce(nerve's Fix 7) builds the newextrain Python from a value read inan earlier statement, then assigns the whole column. A plain
SELECTtakes no SQLite writereservation, so a writer committing an
extrachange between that read and the flush is silentlyclobbered.
Measured with that write inside the window, both its keys are gone while
reinforcement_countstill looks correct. The live store carries the loss: 4.2% of rows lack
extra.content_hash,every one a reinforced row, and still growing.
Both mutations are now computed server-side in one
UPDATE, then read back inside the samestill-open write transaction:
json_setmerges into the column's current value, so another writer's keys survive, and theincrement reads
json_extracton that column in the same statement, so it is atomic;COALESCE(NULLIF(extra, ''), '{}')normalises NULL/empty, as in memory: make the event-date sweep's writes atomic so concurrent writers are not clobbered #247. A non-objectextranow raises rather than being healed from the cache (0 such rows of 156,859,but reachable);
RETURNING: theUPDATEpromotes the transaction to a write transaction, so theSELECTcannot see an interleaved write. The cache is rebuilt from that value.
This also fixes the cold-cache variant: a restart leaves cached
extraat{}, so asnapshot-built column wiped
content_hash. Androwcount == 0now makes a deleted rowdetectable, where the old
if row:returned the ghost anyway: that path evicts the stale entryand creates for real, in #248's shape (which conflicts textually).
_semantic_inmemory_reinforceis unchanged by design: no DB row, no window. memU's owncreate_item_reinforcehash arm andupdate_itemshare this defect, but both are upstream codeneeding a no-double-count wrapper, so a follow-up tracks them.
Validation: 15 new tests (20 cases, two parametrized), 3 racing inside the window; on
main16fail, 4 pass, 0 skip. 18 mutants killed.