Streaming deserialize: add source stream and task - #318
Conversation
02b79c4 to
1349f21
Compare
51f7cfc to
acc181f
Compare
🟡 Waiting for changesLast updated: 2026-08-28 19:18 UTC |
acc181f to
b0b1fd4
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
PR #318 — 12 of 14 prior findings resolved or acknowledged; 2 still open, plus one new suggestion on prefix_conditions (all inline).
HEAD unchanged at b0b1fd4c since the last pass. Test jobs pass on 3.6–3.14 across SQLite and Postgres; the only red check is Handle review requested / Check if author is contributor, a permissions workflow unrelated to the code. No UI files, so Phase 3 did not apply.
Prior-finding status
RESOLVED — morango/sync/stream/source.py:14 — SourceTask declares no __slots__
RESOLVED — morango/sync/stream/source.py:88 — _seen accumulates even when nothing can duplicate
RESOLVED — morango/sync/stream/source.py:75 — "passes thoughts to stream_for_filter"
ACKNOWLEDGED — morango/sync/stream/source.py:80 — Partition-major iteration drops model-dependency ordering
RESOLVED — morango/sync/stream/deserialize.py:69 — Fresh {} per store record gives the FK cache no reuse
RESOLVED — morango/sync/stream/deserialize.py:58 — skip_errored inverted the legacy default
RESOLVED — morango/registry.py:121 — _self_ref_order sort applied to every model
RESOLVED — morango/models/core.py:453 — NULLIF annotation declared BooleanField
ACKNOWLEDGED — morango/models/core.py:444 — Legacy call site in operations.py still hand-rolls the annotation
RESOLVED — tests/testapp/tests/sync/stream/test_deserialize.py:70 — StoreModelSourceTestCase mocked the ORM throughout
RESOLVED — tests/testapp/tests/sync/stream/test_deserialize.py:116 — Both NULL and empty-string deserialization_error against real rows
RESOLVED — tests/testapp/tests/test_registry.py:130 — Asserting returned rows rather than generated SQL
UNADDRESSED — morango/sync/stream/deserialize.py:69 — fk_cache or {} drops a caller-supplied empty dict
UNADDRESSED — morango/sync/stream/source.py:47 — _seen and fk_cache are not reset in begin()
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Ran the same phased review passes as a first review (core, frontend/backend lenses, manual QA when required)
- Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence
b0b1fd4 to
6af15df
Compare
bjester
left a comment
There was a problem hiding this comment.
Code Review: Streaming deserialize — add source stream and task
1. Executive Summary
- Overall Assessment:
COMMENT— no blockers. Two design notes worth resolving before this base class carries the rest of the series. - Summary: All four of #317's deliverables land (
DeserializeTask,StoreModelSource, the sharedMorangoSource/SourceTaskbase, tests), plus theStoreQueryseterror filters andget_store_querysets. The extraction is genuinely a shared base rather than a copy —AppModelSourceshrinks from ~45 lines to 12 and keeps behaviour. The test suite for the ordering guarantee runs against real rows on both backends, which is the right level for something deserialization correctness depends on. - Impact Surface:
morango/sync/stream/{source,serialize,deserialize}.py,morango/registry.py,morango/models/core.py(StoreQueryset/StoreManager). No migration needed — no field changes. No existing code path is rewired, so blast radius on this PR alone is limited to the new source; the risk arrives when it is wired into the deserialize operation. - Verification:
pytest tests/testapp/tests/sync/stream/ tests/testapp/tests/test_registry.py tests/testapp/tests/models/test_core.py→ 119 passed. CI green across 3.6–3.14 / SQLite + Postgres; the only red check is the contributor-check app-token step (infrastructure).
2. Principle-Grounded Findings
⚠️ Architecture & Design (Should-Fix)
-
stream()is a partial function — it raises an opaqueTypeErrorunlessbegin()was called first (morango/sync/stream/source.py:47-51,:89)- Principle: Defensive Contracts — a public entry point should either work or fail with a message that names the violated precondition.
- Issue:
_seenisNoneuntilbegin()runs, andstream()doesobj.id not in self._seen. Confirmed on this tree:Nothing in that message points at the missingTypeError: argument of type 'NoneType' is not iterablebegin(). This is also a narrow regression against the pre-refactorAppModelSource, which initialised_seenin__init__and was safe to call standalone — the evidence is in this PR's own diff, where three previously-passing tests had to gain asource.begin()line (tests/.../test_serialize.py:74,118). - Remediation: initialise in
__init__and reset inbegin(). Keeps the reuse contract thattest_begin_resets_seen_between_runspins, and makes a straystream()correct rather than fatal:If theself._seen: set = set() def begin(self) -> None: """Reset seen set at the beginning of the stream""" self._seen = set()
Nonesentinel is deliberate — "you must go throughPipeline.end()" — then assert it, so the failure names itself:_assert(self._seen is not None, "begin() must be called before stream()").
-
_seengrows unbounded on the filtered path — the memory cost this pipeline exists to eliminate (morango/sync/stream/source.py:84-93)- Principle: the module docstring's own goal — "streamed one-by-one … reducing memory overhead" (
stream/core.py:1-6). - Issue: The no-filter case is already exempted, so what remains is exactly the case that matters: a filtered facility sync retains one 32-char id per record streamed, for the lifetime of the source. At ~130 bytes per set entry that is ~130 MB at 1M Store rows — on the low-resource deployments this design targets.
- Remediation: the dedup can be stateless. Passes differ only by their partition condition, so a record yielded in pass k is precisely a record whose partition matches prefix k — excluding earlier prefixes in later passes is exactly equivalent to the id set, and pushes the work into the query:
with
def stream(self) -> Generator[T, None, None]: processed = [] for prefix in self.prefix_conditions(): for obj in self.stream_for_filter(prefix, exclude_prefixes=processed): yield obj processed.append(prefix)
stream_for_filterapplying.exclude(partition__startswith=p)per entry. Prefix counts are small,processedis bounded by the filter size rather than the row count, and_seendisappears. Holds forpartition_order="desc"too. This is distinct from the prefix-reduction idea you declined earlier — that one traded query count for coverage reasoning; this one removes state without changing either.
- Principle: the module docstring's own goal — "streamed one-by-one … reducing memory overhead" (
💡 Tactical Suggestions & Polish (Optional)
-
begin()wipes a caller-suppliedfk_cache(morango/sync/stream/deserialize.py:69-75):self.fk_cache.clear()mutates an object the caller owns, which makes the constructor parameter unable to do the one thing injection is usually for — handing in a warm cache. The remaining value is post-run inspection, which is legitimate but non-obvious. Either say so on the parameter (:param fk_cache: … cleared at the start of every run) or only clear when the source created the dict itself. -
Boolean-flag method sits in the public queryset API (
morango/models/core.py:444):filter_deserialization_error(has_error)is a flag argument whose two call sites are the intention-revealing wrappers right below it. Renaming it_filter_deserialization_errorleaves callers with only the two readable spellings. (The wrappers themselves are the right shape — this is just about which of the three is the advertised one.) -
The "no cross-partition FK references" invariant lives only in a PR thread (
morango/sync/stream/deserialize.py:80-81): the comment claims model-major streaming gets FK targets deserialized first, which holds globally only because partitions never contain FKs across each other. That premise is the load-bearing part and it is nowhere in the code. One clause — "partitions do not contain cross-partition FK references, so per-partition model ordering is sufficient" — saves the next reader the rediscovery. -
AppModelSourceno longer dedups whensync_filter is None(morango/sync/stream/source.py:90-91): a deliberate and near-certainly safe narrowing (a single pass over non-joined querysets cannot repeat a row), but the test that used to cover it was retargeted to the filtered path (test_serialize.py:92) rather than replaced, so nothing asserts the unfiltered behaviour any more. Worth a line in the PR body if not a test. -
Nothing pins
__slots__effectiveness (morango/sync/stream/source.py:11-18): the__slots__ = ()onSourceTaskis load-bearing and verified working here —DeserializeTask has __dict__: False AttributeError: 'DeserializeTask' object has no attribute 'zzz'— but it is one easily-dropped line away from silently reintroducing a
__dict__on every task in the stream.with self.assertRaises(AttributeError): task.nope = 1inDeserializeTaskTestCasecosts two lines and holds the line for the whole series. -
abc.ABCinMorangoSource(Source[T], abc.ABC)is redundant (morango/sync/stream/source.py:26):Source→PipelineModule→StreamModule(abc.ABC)already suppliesABCMeta. -
Docs and CHANGELOG (
docs/architecture/index.rst:135-176): the "Streaming architecture" section enumerates the stream module vocabulary and the serialization pipeline;MorangoSource,SourceTask, and the now-load-bearingbegin()lifecycle aren't in it. Deferring the prose to the end of the series is reasonable — worth saying so explicitly against the unchecked box rather than leaving it ambiguous.
✅ Positive Highlights
GetStoreQuerysetsTestCaseasserts returned rows, not generated SQL — with the reasoning written into the docstrings, and staying valid across SQLite and Postgres.test_orders_unresolved_parents_lastin particular pins nulls-last for_self_ref_order, which is the subtle half of the ordering contract.StoreModelSourceStreamTestCaseruns against realStorerows and the real registry.test_stream__skip_erroredcovering bothNULLand""is the historical caseNullIfexists for, andtest_stream__models_in_dependency_orderpins the registry-order guarantee at the level a consumer actually depends on.StoreManager(models.Manager.from_queryset(StoreQueryset))— deletes a hand-rolledget_querysetand makes every queryset method reachable from the manager, which is what letexclude_has_deserialization_error()read as one call at the use site.- The
NullIf-over-ORcomment (core.py:449-450) records why the odd-looking annotation is there, on both backends. That is the comment that stops someone "simplifying" it back into anOR. - Gating the
_self_ref_ordersort onget_self_referential_fk(registry.py:121) keeps the majority of models off an unindexed sort while preserving the tree ordering where it is needed.
3. Reviewer Guidance, Answered
Do the tests cover the new and refactored code enough?
Coverage of the new code is good, and the real-row tests are the right choice. Two gaps, both listed above: __slots__ effectiveness, and unfiltered AppModelSource dedup (whose old test moved to the filtered path).
Did I properly translate the self-ref-ordering and deserialization error filtering?
Yes on both, as far as this PR's surface goes.
- Error filtering is a faithful extraction of
operations.py:326-336, and theTextField()output field is more accurate than theBooleanField()it came from. Both null and""are handled, and tested against real rows. - Self-ref ordering matches the semantics
_update_legacy_self_ref_order_for_modelestablishes (operations.py:728-745): roots at0, children at parent+1,NULLwhen unresolvable — so ascending-nulls-last is parents-before-children with the unresolvable tail last. Correct.
One difference to carry forward rather than fix here: get_store_querysets scopes each queryset to a single model_name, where _deserialize_from_store ORs in morango_model_dependencies (operations.py:307-311). That is the better shape — the registry already orders dependencies first via _insert_model_in_dependency_order, so each record is now covered exactly once instead of revisited — and it matches get_model_querysets. Worth knowing when the consumer lands, since the legacy self-ref branch computed clean_parents/dirty_children over that wider set.
The other thing the consumer will have to supply: the legacy self-ref branch didn't just order records, it classified the failures — MorangoDirtyParent and MorangoMissingParent (operations.py:380-396). Ordering by _self_ref_order replaces the iteration but not the diagnostics.
4. Next Steps
- Make
stream()safe or self-diagnosing withoutbegin()(source.py:47) - Decide on stateless prefix-exclusion vs. keeping
_seen, and record which and why - Document or narrow
begin()'s clearing of an injectedfk_cache - Optional:
_filter_deserialization_errorrename;__slots__regression test; cross-partition-FK invariant in the comment; drop redundantabc.ABC - Confirm docs/CHANGELOG are intentionally deferred to a later part of the series
Summary
StoreQuerySetmethods that apply filtering for selecting records based on whether they have deserialization errors. These can be used in Kolibri laterTODO
Reviewer guidance
Issues addressed
Closes #317
AI Usage
I used Claude to do the rebase and bring it up-to-date with the upstream changes. It made a rightful mess of it, and so I rewrote some of the changes to better follow the patterns and then had it clean up the mess by updating tests.