fix(storage): persist the pre-compression neuron snapshot instead of dropping it - #192
Conversation
…dropping it neuron_snapshots.compressed_at is declared TYPE string, but the writer sent a datetime. On a SCHEMAFULL table SurrealDB refuses the write, and the only caller swallows the failure and compresses anyway - so a destructive tier replaced a neuron's content while the snapshot meant to protect it never existed, leaving recovery nothing to restore from. Send the declared type. get_neuron_snapshot already parses through _parse_datetime, so callers keep receiving a datetime. Covered by a live regression test over both the insert and the merge path; the mismatch cannot surface on the schemaless in-memory backend.
acidkill
left a comment
There was a problem hiding this comment.
Reviewed against main@ae8e8743, verifying the claims independently rather than taking the description at face value.
Diagnosis confirmed. neuron_snapshots.compressed_at is TYPE string DEFAULT '' (schema.py:396), while the sibling compression_backups.compressed_at is TYPE datetime (schema.py:385) and its writer still correctly sends a datetime. The fix is scoped to the one column whose writer disagreed with it, with no sibling regression.
Checks performed:
- Both write paths covered.
insertandmergeshare the samerecord_datadict, so.isoformat()applies to the upsert branch too, not only the first insert. Worth stating because a fix applied to just one branch would have looked identical in the diff. - Read path unchanged.
get_neuron_snapshotstill routes the value through_parse_datetime, which acceptsstranddatetimealike, so callers keep receiving adatetime. - No other writer. Nothing else in the tree writes that column, and no query orders or compares on it, so storing an ISO string has no downstream effect.
- Fail-soft caller confirmed. The only caller wraps the call in
except Exception: logger.error(...)and compresses anyway, which is exactly why the refusal was invisible. - Test hygiene. The throwaway brain name is registered in
LIVE_TEST_BRAIN_NAMES, so the live test cleans up after itself.
The "no data migration needed" argument holds: the write never landed, so there is no legacy value in the column to stay compatible with.
The reasoning for preferring the writer-side fix over redefining the column is also correct — ensure_schema swallowing already exists on a re-DEFINE means a schema edit alone would leave existing databases broken. Agreed that normalising the column belongs in a numbered migration rather than folded in here.
No findings. Approving.
Summary
save_neuron_snapshotnow sendscompressed_atas thestringthe schema declares, so the pre-compression snapshot actually reaches the database instead of being refused.Why
neuron_snapshots.compressed_atis definedTYPE string, but the storage layer wrote adatetimeinto it. On a SCHEMAFULL table SurrealDB refuses the write:The only caller -
CompressionEngine, saving originals before the destructive tiers - wraps the call in a fail-softexceptthat logs and then compresses anyway. So the failure was invisible in normal operation: tier 3-4 compression replaced a neuron's content while the snapshot meant to protect it silently never existed, andrecover_neuron_contenthad nothing to restore from. The counterpart tablecompression_backups.compressed_atisTYPE datetimeand receives adatetime; only this one column disagreed with its writer.Sending the declared type is the smaller of the two possible fixes and the one that works everywhere. The alternative - redefining the column as
datetime, which would read more naturally and matchcompression_backups- cannot be delivered by editing the schema alone:ensure_schemaswallowsalready existson a re-DEFINE, so an existing database would keep thestringcolumn and stay broken. That route needs a numbered migration and aSCHEMA_VERSIONbump, which is a larger change than this fix and touches every deployment; I have deliberately left it out rather than fold it in here. Happy to follow up with it if you would prefer the column normalised.Two details worth noting for review:
1f6fe80d, Orphan-prune: pinned fixed (PR #17), but should isolated neurons get the same age/access grace as dead ones? #28) and neither line has changed since, so noneuron_snapshotsrow has ever been written on a SurrealDB backend. There is no legacy value in the column to be compatible with.get_neuron_snapshotalready parses through_parse_datetime, which accepts both a string and adatetime, so callers keep receiving adatetimeexactly as before._parse_datetimedropstzinfowithout converting to UTC, so an offset-bearing input would be stored as local wall time labelled UTC. That is pre-existing behaviour and the only in-tree caller passesutcnow().isoformat(), which is already naive UTC - but this value reaches the database for the first time with this fix, so it is worth stating rather than leaving to be discovered.Test plan
pytest tests/ -m "not stress" -n autopasses locally: 7079 passed, 148 skipped, 1 xfailed. The skip count is +2 against the same run onmain- precisely the two new tests, which skip whenSURREALDB_URLis unset.ruff check src/ tests/clean;ruff format --checkclean.mypy src/ --ignore-missing-imports-Success: no issues found in 353 source files.surrealdb:v3.2.4, ephemeral instance, realensure_schema): before the fixget_neuron_snapshotreturnedNoneandSELECT count() FROM neuron_snapshots GROUP ALLreturned0; after it, the snapshot reads back with its content and tier intact and the count is1.SURREALDB_URL, so it runs in the integration job and skips elsewhere; its throwaway brain name is registered with the shared cleanup helper so it does not accumulate rows.A note on why the test needs a live engine: this failure is a schema coercion refusal, and the in-memory backend stores snapshots in a plain dict with no schema, so the mismatch cannot surface there. That is also why nothing caught it earlier - before this change,
save_neuron_snapshothad no test coverage at all.Verified by
@RobertSigmundsson
Rebased onto v3.8.0 (
ae8e8743) and re-tested there. #186 landed in the same area - theneuron_statewrite - but the paths are disjoint: this one is the snapshot row inneuron_snapshots, which #186 does not touch. Thanks for the quick turnaround on bothv3.7.0 and v3.8.0.