Repository navigation
Conversation
d7d0fc1 to
0cf4927
Compare
0cf4927 to
af3a4f4
Compare
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…etions Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
af3a4f4 to
17aeef1
Compare
🟡 Waiting for changesOutcome: reviewed Last updated: 2026-10-20 01:03 UTC |
bjesterbot
left a comment
There was a problem hiding this comment.
Executive Summary
This PR successfully completes the streaming deserialization migration (#279, #210). The implementation gives deserialization the same streaming pipeline architecture (StoreModelSource → AppModelDeserialize → AppModelValidate → Buffer → DeserializeSink) as serialization, eliminating unbounded memory usage during sync.
The resolution of the "sink lag" challenge with deferred foreign keys (DEFERRABLE INITIALLY DEFERRED) is particularly well designed: tracking both failed saves (failed_ids) and cascade-deleted rows (cascaded_keys) in DeserializeSink ensures that broken foreign key references are caught and recorded as individual Store errors rather than failing the transaction at commit time.
Findings Summary
- 🚨 Blocker (Must-Fix): None.
⚠️ Architecture & Design (Should-Fix): None.- 💡 Tactical Suggestion (Optional):
morango/sync/stream/deserialize.py:542: Record error ontask.errorsin_record_errorto keep in-memoryDeserializeTaskstate synchronized.morango/sync/stream/deserialize.py:462: Guard or assert chunk model uniformity inDeserializeSink.consumewhen callingdelete_app_models.
- ✅ Positive Highlights:
- Pre-save and post-save signals are muted once for the entire batch of saves.
- Cascade tracking inspects
collector.fast_deletesviavalues_list("pk", flat=True)to track deleted rows without suppressing Django fast-deletes. - Comprehensive legacy parity tests with frozen literal expectations guarantee backward compatibility.
Verification Status
- Test Suite: 644 passed, 2 skipped (
pytest). - Pre-commit / Linters: Clean (
prek run --all-filespassed ruff, ruff format, yaml, and file hygiene checks). - Django Migrations:
makemigrations --checkreports no unapplied schema changes.
Comments on Lines Outside Diff
(None)
Generated by Antigravity using model Gemini 3.8 Flash
rtibblesbot
left a comment
There was a problem hiding this comment.
PR #392: no correctness bugs found. Chunk ordering, delete-before-save, cascade tracking and FK-failure propagation match the removed legacy path.
CI pending.
- suggestion:
deferred_clean_fieldsnow has no production caller (inline). - nitpicks: one type annotation, rationale comments, duplicated and brittle tests (inline).
After this review: Full code review.
- Confirm the streaming
deserialize_from_storepath preserves sync semantics for downstream (Kolibri) callers, since the legacyStore._deserialize_store_modelanddefer_fksare gone. - Check that the
morango.sync.stream.deserializelogger rename in the changelog is acceptable for anyone's log config.
Written by rtibblesbot, an LLM-based coding agent.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
FK constraints are deferred, so saving a record whose target the sink failed or removed earlier in the run would only fail at commit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Defaults the test Facility to not load its parent during validation, like models that don't load related objects. With sink lag, a dirty chain of models that do takes one run per level, so the test that deserializes a whole chain at once relies on the new default. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Moves single-record deserialization to a standalone function on the streaming transforms and write helpers, so it can still be called outside of a pipeline run. It returns the validated app model, or None for a deleted record. The defer_fks option is dropped, since only the removed legacy bulk path used the deferred FK references. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The sink's summary test already checks the counts, so the log test doesn't need to depend on the formatting of the summary dict. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
c106140 to
3057b29
Compare
|
Follow-up on the two checks in the review summary from @rtibblesbot:
|
rtibblesbot
left a comment
There was a problem hiding this comment.
PR #392: 4 of 6 prior findings resolved; 2 acknowledged. No new findings. CI pending.
Prior-finding status
ACKNOWLEDGED — morango/models/core.py:546 — deferred_clean_fields has no production caller (tracked in #396)
RESOLVED — morango/sync/stream/deserialize.py:445 — counts annotation looser than its keys
ACKNOWLEDGED — morango/sync/stream/deserialize.py:521 — comments restating rationale
RESOLVED — tests/testapp/tests/sync/stream/test_deserialize.py — duplicate of test_failed_parent_save__cross_model
RESOLVED — tests/testapp/tests/sync/stream/test_deserialize.py:1683 — self-referential one-level-per-run test
RESOLVED — tests/testapp/tests/sync/stream/test_deserialize.py:1725 — "'save': 1" pins dict repr
After this review: Full code review.
- Confirm the streaming pipeline fully replaces the removed legacy deserialization in
operations.pyandStore, including error and cascade-delete semantics other sync code relies on. - Check the 0.9.0 changelog entry describes the behaviour change accurately for downstream integrators.
Written by rtibblesbot, an LLM-based coding agent.
Summary
This change completes the streaming deserialization migration. It adds the sink, switches all callers to the new pipeline, and removes the legacy
_deserialize_from_storepath. Deserialization now runs as a stream, like serialization:New code in
morango/sync/stream/deserialize.pyDeserializeSinkwrites the resolved outcome of each task, one chunk at a time:SAVE: The sink saves each record in its own savepoint, withpre_saveandpost_savemuted.DELETE: The sink deletes the app rows with one DjangoCollectorfor each model in the chunk.PROPAGATE_DELETEandPROPAGATE_HARD_DELETE: The sink adds the records to the tracking tables with onebulk_create(ignore_conflicts=True)for each table.ERROR: The sink saves the error on the Store record.dirty_bit=Falseand clears the error fields on a Store record. The sink does the clean writes of a chunk with oneQuerySet.update.bulk_updateas slower than saves of single records.deserialize_from_store(profile, sync_filter=None, skip_errored=False)is the entry point. It has the same shape asserialize_into_store, and it logs a count for each outcome.deserialize_store_model(store, fk_cache, sync_filter=None)deserializes one Store record outside of a pipeline run. It returns the validated app model, orNonefor a deleted record.deserialize_store_modeluse the same write helpers,delete_app_modelsandrecord_propagated_deletions.Why the sink tests each save against two sets
The buffer holds up to one chunk of validated records before the sink writes them. FK constraints are
DEFERRABLE INITIALLY DEFERRED. As a result, a save that breaks a constraint succeeds in its savepoint, but the transaction fails at commit.Two writes of the sink can remove an FK target after a record validated against it:
DELETEcascades to app rows that a dirty record references.The sink records the IDs that failed and the keys of the cascaded rows. If a
SAVEreferences one of them, the sink saves an error on its Store record and does not save the app model. Each record with a problem gets its own error, and the transaction always commits.Callers and removal
ReceiverDeserializeOperationandMorangoProfileController.deserialize_from_storecall the new entry point. The public signature of the controller does not change._deserialize_from_store,_save_deserialized_record,_validate_store_foreign_keys,_validate_missing_store_foreign_keysand_handle_deleted_store_foreign_keys. It also removes the imports that only these functions used.Store._deserialize_store_modelwithdeserialize_store_model. The new function returns the app model orNone, not a tuple, and it has nodefer_fksoption.CHANGELOG.mdgets a new0.9.0section that lists these changes. It names the replacement for each removed function, so that Kolibri can use it for its upgrade.Behavior differences from legacy
dirty_bit=False. Legacy left it dirty.skip_erroredor by the sync filter. Its children now validate against the older app row of the parent, and the sink saves them. Legacy failed them withMorangoMissingParent. This difference comes from the Part 2 transforms (Streaming deserialization part 2: transformations #390).clean_fields. For these models, a child in the same chunk as its dirty parent cannot load the parent yet, so it fails withMorangoDirtyParent.clean_fields, so this does not affect Kolibri.Facility.clean_dereferences_parentnow defaults toFalseto match Kolibri. Tests set it toTrueto cover the models that load related objects.deserialize_store_modelraises the error that the transforms resolved. This error can beMorangoDirtyParent,MorangoMissingParentorIntegrityError.morango.sync.stream.deserialize, notmorango.sync.operations.Not in scope
post_deletestays muted, as in legacy.deferred_clean_fields,ForeignKeyReference,TemporaryTableand the backend bulk helpers lose their only callers. Remove the deferred FK validation API and unused backend bulk helpers #396 tracks their removal before 0.9.0 ships.References
_deserialize_from_store#210, for the memory use of deserialization.Reviewer guidance
Verification
c106140, before the changes from review.prek run --all-filespasses, andmakemigrations --checkreports no changes.DeserializeFromStoreTestCasetests that need the run to commit failed at commit withFOREIGN KEY constraint failed.Test changes
dirty_bitbecomesFalse.test_dirty_parent_not_in_run: The sink now saves the child and it ends clean. Legacy failed it withMorangoMissingParent.DeserializationTestCases, the expected results do not change. Only the call and its keyword arguments change.test_controller.py, only the logger name intest_unique_violation_logs_warningchanges.SelfReferentialFKDeserializationTestCase::test_models_created_successfullypasses only because of the newFacilitydefault. It deserializes a full dirty chain in one run, which is the multi-run difference above.Areas to read carefully
DeserializeSink.consumewrites in this order: errors, deletes, propagated deletions, saves, then the Store records. The deletes come before the saves, so that the save guard can see the rows that a delete in the same chunk cascades to.delete_app_modelsbuilds itsCollectorwhilepost_deleteis muted. Django looks for signal receivers when it decides to fast-delete, so the signal must be muted first.SELECT, and only in chunks that have deletes.AI usage
I used Claude Code (Claude Opus 5.5) for this part, in a workflow of spec, plan, then implementation. I drafted the design spec and the implementation plan with Claude, and I reviewed and approved both.
A second AI review of the spec found three problems before implementation. We added the cascade case, replaced a query-count criterion that SQLite cannot meet, and documented the effect of the new
Facilitydefault.I made the main decisions: the buffered sink, saves of single records instead of
bulk_update, and a standalone function for the deserialization of one record. Claude implemented the plan one task at a time, and wrote failing tests first. Claude also captured the legacy parity values. It also turned off the guards and made sure that the new tests fail when a deferred constraint breaks.