Skip to content

Review the Python oracle: three capture-path fixes, and tests for what was unpinned - #137

Merged
zaoxing merged 9 commits into
mainfrom
polish/src-dmi
Sep 22, 2026
Merged

zaoxing merged 9 commits into
mainfrom
polish/src-dmi

Conversation

@zaoxing

@zaoxing zaoxing commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

What

A review pass over src/dmi/ — the Python oracle. Three behaviour fixes and
six tests that pin behaviour which was previously unpinned or pinned by a stub.

The scope was chosen deliberately. src/dmi/ is the reference implementation
every native C++ fix is verified against: it has been the measuring stick for
roughly twenty findings and had never itself been reviewed. A defect here is
worse than one in the port, because the port's conformance tests would
faithfully reproduce it.

Production diff is 52 lines across 3 files; the remaining ~921 lines are
tests.

Based directly on main (#133), so it is independent of #132, #134 and #135.
No production file overlaps any of them — see Overlap below.

The three behaviour fixes

The footer cache returned descriptors without re-checking the tenant bind

reader.py caches pack footers by content identity. On a hit it returned the
cached descriptor directly, skipping _reject_a_foreign_tenant — so a second
caller, under a different tenant, could read a descriptor the bind would have
refused.

The fix re-runs the location bind on the hit rather than widening the cache
key. Adding object_key to the key was considered and rejected: that leaves a
content-identity cache silently carrying a location-derived result, which is
the same defect one layer down. _reject_a_foreign_tenant is now
reject_a_foreign_tenant(ref, tenants) so the reader can call it against a
cached descriptor.

The date= object-key segment was computed by float division

pipeline.py:296 divided captured_at_ns by 1e9, which rounds. The C++
port truncates. Now // 1_000_000_000.

The divergence window is 596 ns per day boundary, measured rather than
estimated: …999999403 agrees, …999999404 diverges. A capture landing in
that window is written under tomorrow's date= partition by Python and
today's by the port — the two implementations disagree about where the object
lives, which is exactly the class of drift the conformance suite exists to
catch and could not see here.

A manual flush could orphan its barrier at the failure seam

If the worker failed between a flush installing its barrier and the worker
reaching it, the barrier was never woken and the flush waited forever. Now the
failure handler wakes it.

The six pins

These change no production behaviour. Each was verified red-then-green by
reverting the source under test and confirming exactly one test fails.

  • Three CPU-direct record transforms had zero coverage.
  • The dtype/shape bind was validated by a stub carrying its own copy of the
    check and its own message — grep found that message only in the stub and
    the match= asserting it, so the test passed with the product's guard
    deleted. The fix was to make the stub delegate.
  • Flush-after-close: deleting either guard turns a prompt False into an
    unbounded hang.
  • Flush against a failed pipeline: the reuse path could return True on a
    failure.
  • The failure seam's other arm — the barrier wake — which the fix above
    left unpinned, because its new test parks flush before the barrier exists.
  • The record-runtime rollback's stop-before-drop ordering.

Checks

Signal Base 214bdb2 Tip 9aaa871
make -C native cpu-goals (forced -B) 0 errors, 0 warnings 0
pytest -m cpu 1462 passed, 0 skipped 1499 passed, 0 skipped
CI cpu skip gate — 1499 collected, 0 unexplained
pytest -m "clickhouse and manual and not garage" 198 passed, 1 failed 198 passed, 1 failed
CI live skip gate — 199 collected, 0 skipped
compileall src/dmi clean clean

The one live failure is pre-existing and environmental:
test_a_role_that_cannot_see_one_object_is_told_to_grant_it_not_to_rebuild
needs CREATE USER, and the local standalone ClickHouse has no access
management. It passes in CI.

The build check forces -B on purpose — without it, cached objects suppress
re-emission and a warning count is vacuous.

Threading stability: 30 runs, zero flakes. Twenty full-file runs plus ten
under 48-way CPU saturation on 32 cores. Latency rose ~1.7x with no
behavioural change, which is the actual proof: the interleavings are forced by
synchronisation primitives rather than sleeps, so they hold under load.
Per-test JUnit parsing confirmed all five interleaving tests ran in all twenty
runs and none silently deselected.

make test-package is NOT MEASURABLE on this host, and is not counted as
green: it dies inside the stdlib (venv.EnvBuilder(with_pip=True), ensurepip
rc=127), reproduced with zero repo files involved. The parts that could
regress from source changes do pass — the wheel builds, and _validate_archive
returns clean. Only the venv smoke-install is unmeasured.

Overlap

No production file here is touched by #132, #134 or #135. Three test files
collide and will need a trivial merge whichever lands second:

Known, not fixed

  • record_adapter.py:240-241 is dead code, shadowed unconditionally by the
    is_running check at 231. Found during verification. It is a code change
    rather than a missing test, so it is out of scope for a behaviour-preserving
    pass and wants its own PR.
  • The -Wall -Wextra authority has a hole. Two of the nine cpu goals
    compile without it — bench_builder and _dmi_native_sink — so "0 warnings"
    says nothing about bench_builder.cpp, native_pack_sink.cpp or
    bindings_sink.cpp. Pre-existing and untouched by this Python-only diff.
  • 23 findings confirmed but classed optional, each with its evidence.
    They are a maintainer's call, not the pass's.

How to read the ledger

56 findings reported, 26 verified, 9 fixed: 5 refuted outright, 13
confirmed-then-downgraded, 1 deduped. The gap is the point — every refutation
and downgrade would otherwise have been a fix applied on a reviewer's say-so.
Reachability was the sole reason for the downgrades: the mechanism was real and
demonstrated, and no production caller could reach it.

The footer cache is keyed on pack identity -- store, pack id, checksum --
and a hit returned its descriptors straight back. PackIndex.from_store is
the only place the tenant/key bind runs on the read path, so a pack PUT at
a second key belonging to another tenant was refused cold and served warm;
within one hydrate() call, which of the two happened came down to the
order of the selection's capture ids, since _plan groups by object key and
walks the groups in that order. The cache key is not a content identity
either -- for S3 the checksum is the uploader-DECLARED dmi-sha256 -- so a
different object at the foreign key could be served as the victim's
capture, which neither _require_footer_match nor verify_payload can catch.

Adding the object key to the cache key would close this instance while
leaving the entry carrying a LOCATION-derived verdict under a content
identity, so the next location check added would regress the same way.
The bind is recomputed on the hit instead: reject_a_foreign_tenant now
takes the tenants rather than the records, so the reader can run it
against cached descriptors. One footer read per pack however many keys it
legitimately sits at, which the positive-control test pins.
IDENTITY, PREFIX_STRIP and CHUNKED in
`RingTransport._record_cpu_tensor` had no test at all: the whole suite
called that function exactly twice, both times through SEGMENTED_PACK or
SEQ_PREFIX_PACK, so corrupting all three branches at once left the suite
byte-identical.

Pin them against in-test transcriptions of the kernels they must match
(`record_producer_static_kernel`, `record_producer_prefix_kernel` and
`record_producer_chunked_kernel` in native/csrc/ring/producer.cu) rather
than against more hand-written literal tensors, so the module docstring's
claim about matching CUDA producer bytes is actually what is checked.

The cases cover the edges that tell the implementations apart: a row
count that is negative, zero, exactly `n // row_bytes` and past it; chunk
byte counts that are negative, zero and wider than their chunk; and
multi-dimensional, multi-dtype IDENTITY tensors. IDENTITY returns the
source dtype and shape, not a flat uint8 run, and the test asserts that.

No production code changes: the transforms are already byte-for-byte
correct against the CUDA kernels.
`object_key_for` divided `captured_at_ns` by 1e9 in float, and the
division rounds. For a capture in the 596 ns window before a UTC
midnight the rounded second lands on the next day, so the Python sink
named a day the capture provably did not happen on. The C++ port
(native/csrc/sink/object_key.cpp) truncates and was already right, so
the two sinks disagreed on the same input:
1767225599999999404 gave Python 2026-01-01 and native 2025-12-31.

`captured_at_ns` is a non-negative nanosecond count, so floor and
truncation coincide: `//` is the whole fix, and the C++ side is left
alone.

Keys written by the old code inside that window are unaffected and stay
readable -- nothing parses the `date=` segment; it is a partition hint
only -- so this is not an on-disk format change.

The parity test now drives the native key builder with the diverging
timestamp and both of its neighbours, so the seam cannot be moved
instead of removed.
`flush` published its barrier and enqueued it without re-checking
`_error`. The worker's failure handler snapshots `_pending_flush` and
only then closes the queue, so an interleaving exists where the handler
sees no waiter, the publish lands afterwards, and `put_barrier` is
accepted onto a still-open queue whose only consumer has already left
its loop. Nobody ever completes that barrier.

With a finite timeout the caller degraded to a spurious `False`; with
`timeout=None` it waited forever while holding `_flush_lock`, so every
later flush from any thread returned `False` too. No in-tree caller can
pass `timeout=None` (the value comes from bindings.cpp, which rejects
non-finite timeouts), but `HostCapturePipeline` is public, so the hang
was reachable from outside.

Re-read `_error` after a successful `put_barrier`, mirroring the existing
CLOSED branch. The publish happens-before that re-read, so observing no
error proves the handler's snapshot has not run yet and will therefore
see this barrier. The alternative -- closing the queue inside the
handler's `_lock` -- would force `_lock` to be held across `put_barrier`
and create a lock-ordering constraint against the queue condition, so it
is not taken.

The test drives the interleaving with events only: a sink that blocks
then raises, a `_FlushBarrier` that parks flush between its `_error` read
and the publish, and a `_queue.close` that parks the handler between its
snapshot and the close. Nothing depends on timing, and the flush timeout
is finite so a regression asserts rather than hangs.
The seam test added in 201093c parks flush() before its barrier object
exists, so _pending_flush is still None when the worker's failure handler
snapshots it. That pins the _error re-read arm and leaves the other one --
the ordinary case, where the sink raises on a record queued ahead of an
already-waiting barrier -- covered by nothing: replacing the handler's
wake with 'if False: pass' left the whole cpu suite green.

Add a sibling that forces the opposite interleaving by gating the worker's
failure on flush having entered completed.wait(). Entering the wait is the
observable proving the publish, a successful put_barrier and the _error
re-read have all already happened with no error latched, so the handler's
wake is the only thing left that can complete the barrier. The flush
timeout stays finite so a regression asserts rather than hangs.

The existing seam test is untouched: the two arms need opposite
interleavings, and a control mutation of the re-read still fails it alone.
_CapturePackTarget._submit_capture's dtype and shape checks are the only
thing tying a native-produced tensor to the metadata written into the pack
footer, and both could be replaced with 'pass' with the entire suite still
green. The cause was the e2e stub: _NativeValidationTarget reimplemented
the dtype check with its own message, so the only test asserting on it
asserted a string the product never emits, and the stub had no shape check
at all.

Make the stub delegate to a real _CapturePackTarget over a throwaway
pipeline and assert the product's own message, then add the two cases with
no downstream stand-in, as pure-CPU direct calls so they run in the gate
that skips the whole e2e file: an equal-width dtype swap (int32 (3,4) over
a float32 (3,4) payload) and a permuted shape (float32 (3,4) over a (4,3)
payload). Both are 48 bytes either way, so the pack model's total-bytes
compare and verify_payload's length + CRC32 are satisfied by either and
the footer would describe bytes it does not match.

Parametrize ids are now explicit because the last case carries a full
metadata document that pytest would otherwise splice into the test id.
…alse

Two dark arms: BoundedRecordQueue.put_barrier's CLOSED return and flush's
CLOSED handling. Either regression turns flush()-after-close() into a hang
for the timeout=None callers HostCapturePipeline still allows, and
record_adapter._flush_capture has no _closed guard to stop it.

The pipeline test replaces the barrier's completed event rather than timing
the call, so a regression fails an assertion instead of sleeping.
…barrier

Two dark arms of flush(). The latched-_error raise is behaviourally
redundant (the CLOSED arm re-raises the same error) but its caller state --
flushing after the worker already died -- had no end-to-end test at all.

The reuse-path raise is load-bearing: without it a second flush that inherits
a barrier which errored, with no records submitted since, falls through to the
target comparison and returns True on a failed pipeline.

Both tests gate every step on an event and keep flush timeouts finite.
The post-switch rollback arm was dark in cpu and live: an `except
BaseException: raise` mutant survived the whole gate. Dropping the Python
owners alone does release the lease, so a leak assertion proves nothing; what
the arm buys is that the GIL-releasing stop() runs before the destructor path
joins the record worker with the GIL held, which is what keeps a Python sink
from deadlocking.

The test asserts the order first, then the rolled-back engine state.
Copilot AI lite review requested due to automatic review settings September 22, 2026 01:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Samfisheryu Samfisheryu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 9aaa871. No blockers found. Independently passed 1,499 CPU tests and 53 live ClickHouse E2E/native-reader parity tests; restoring each of the three original implementations reproduced the corresponding regression. Sink-routing checks also pass. This PR leaves the shared Ring, sink-selection boundary, and existing production ClickHouse path unchanged. The Python reader fixes also apply when reading native-written capture packs. GPU E2E was not rerun for this Python-only production diff.

@zaoxing
zaoxing merged commit 079a90e into main Sep 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants