Skip to content

Scan Sintela protobuf recordings without reading them - #865

Merged
d-chambers merged 1 commit into
devfrom
sintela-protobuf-scan-perf
Aug 11, 2026
Merged

Scan Sintela protobuf recordings without reading them#865
d-chambers merged 1 commit into
devfrom
sintela-protobuf-scan-perf

Conversation

@d-chambers

@d-chambers d-chambers commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Description

Scanning a Sintela protobuf recording read every byte of the file. _iter_envelope_records pulled each record's full payload — including the sample blobs, ~99.99% of the file — just to reach the ~100-byte header at the front of each packet. Indexing a directory therefore cost a full sequential read of every file in it.

Measured on a 10,308 file archive of 505 MB recordings:

before after
dc.scan, per file 2.05 s 0.02 s
bytes read per file 505 MB 1.3 MB
whole directory 5.9 h 3.3 min
spool.update(), per file 1.78 s 0.03 s
read peak memory (0.50 GB patch) 2.0x the patch 1.02x

The generic scan-cost guard and the benchmarks that came out of this work are split into #868, stacked on this branch.

What changed

Timeseries scans read two packets. sample_count numbers the samples preceding each packet, so the first and last headers give the total directly. The final record is located by searching back from EOF for the framing whose extent lands exactly on EOF.

The shortcut is taken only when the file is demonstrably one contiguous run. Three independent checks: each endpoint's declared length must fit its own record, the total must fit the file, and the endpoint timestamps must match the span those samples imply. A concatenated file, a counter that reset, or a mid-file reconfiguration fails one of them and falls back to reading everything. On the archive above, 10,306 of 10,308 files take the shortcut; the two that don't are genuinely damaged (one truncated, one with channels changing mid-file) and both still raise on read.

The timestamp tolerance is calibrated against that archive rather than guessed: a quarter of the files drift (recorder clock resync), worst 56 ms over a 60 s span, and the tolerance sits an order of magnitude above that. A false rejection silently costs a full read, while what is worth catching is wrong by seconds to hours.

BAND and FFT scans need every packet's timestamp, so they still visit every record, but are handed each packet's header without its samples. That bounds their memory, not their I/O — skipping a sub-2 MiB payload with seek measured slower than streaming it on spinning media (a seek costs a platter rotation), so small remainders are read and dropped.

Reads stream. read previously decoded all packets before copying them into the output array, holding the data twice. It now fills the preallocated array packet by packet. Headers are copied into fresh header-only messages and the decoded packet dropped, because protobuf frees an arena only when the whole message dies — clearing the sample field in place does not release it.

get_format no longer pulls payloads it never inspects.

Behaviour change

A timeseries scan no longer detects a gap between the endpoints when the timestamps corroborate it (a paused and resumed acquisition), and reports the span they imply. read validates every packet and still raises InvalidFiberFileError. Reads are byte-identical to before: verified by sha256 over the full data array, plus coords and attrs, on real files including the archive's unusual shapes.

Testing

30 new tests, and protobuf_utils.py is at 100% line and branch coverage. The tests were shaped by mutation testing rather than written to the implementation — several early ones passed for the wrong reason (a fixture that silently skipped, an assertion against the constant under test, a check satisfied via the fallback path) and were rewritten to pin the behaviour they claim to.

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes.
  • documented the new feature with docstrings and/or appropriate doc page.
  • included tests. See testing guidelines.
  • added the "ready_for_review" tag once the PR is ready to be reviewed.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Sintela protobuf handling now supports bounded framing reads, endpoint-based timeseries metadata, streaming sample decoding, counter rollover validation, and fallback parsing. Tests cover malformed framing, endpoint shortcuts, memory use, payload bounds, and validation behavior.

Changes

Sintela I/O optimization

Layer / File(s) Summary
Bounded protobuf parsing
dascore/io/sintela/protobuf_utils.py, tests/test_io/test_sintela/test_protobuf.py
Envelope parsing validates payload bounds, skips payloads during metadata-only iteration, discovers records from both file ends, and centralizes packet parsing. Tests cover malformed framing and truncated payloads.
Endpoint metadata and streaming read integration
dascore/io/sintela/protobuf_utils.py, tests/test_io/test_sintela/test_protobuf.py, docs/changelog.qmd
Timeseries metadata accepts endpoint-derived sample totals. read_payload and scan_payload use validated first and last packets with fallback parsing. Tests cover shortcut reads, counter rollover, endpoint bounds, and scan behavior.
Parser and streaming validation
dascore/io/sintela/protobuf_utils.py, tests/test_io/test_sintela/test_protobuf.py
decode_stream fills the output array while retaining packet headers for final validation. Tests cover packet-family changes, metadata placement, counter resets, output bounds, and retained memory.

Possibly related PRs

  • DASDAE/dascore#656: Introduced the Sintela protobuf parsing, scanning, and decoding functionality extended by this change.

Suggested labels: IO, patch

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main optimization to avoid reading full Sintela protobuf payloads during scans.
Description check ✅ Passed The description explains the problem, implementation, behavior changes, benchmarks, tests, and checklist status in sufficient detail.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sintela-protobuf-scan-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added benchmark Run the benchmark suite IO Work for reading/writing different formats patch related to Patch class labels Aug 11, 2026

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/test_io/test_sintela/test_protobuf.py (2)

1232-1249: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the duplicated _Buf helper to module scope.

This _Buf class is defined again, byte for byte, at Lines 1689-1706 in test_find_last_record_returns_none_without_framing. Move one copy to module scope next to _CountingReader and use it in both tests.

Note that seek ignores whence=1. Both current callers use absolute seeks only, so this works today. A shared helper makes that limitation visible in one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_io/test_sintela/test_protobuf.py` around lines 1232 - 1249, Move
the duplicated _Buf helper from the test-local scope to module scope beside
_CountingReader, preserving its current seek, tell, and read behavior. Remove
the second definition in test_find_last_record_returns_none_without_framing and
update both tests to reuse the shared _Buf class.

704-724: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that the endpoint shortcut engages before asserting the overrun error.

This test targets the overrun guard in decode_stream. That guard only runs when _get_endpoint_metadata returns a shortcut. Here the endpoint time check passes at the exact tolerance boundary: expected_ns is 1.5 s, elapsed_ns is 3.0 s, and the tolerance is _ENDPOINT_TIME_PACKETS * packet_ns = 1.5 s. If _ENDPOINT_TIME_PACKETS or _ENDPOINT_TIME_TOLERANCE is retuned downward, the shortcut declines, the full path raises the same "Non-contiguous" error, and this test keeps passing while the overrun guard becomes untested.

test_short_final_packet_is_measured_from_the_last_endpoint already uses this pattern at Line 753.

♻️ Pin the branch under test
         path = write_sintela_file("ts_overrun.pb", records)
+        # Pin the branch: the overrun guard only runs on the shortcut path.
+        with path.open("rb") as handle:
+            assert sintela_utils._get_endpoint_metadata(handle) is not None
         with pytest.raises(InvalidFiberFileError, match="Non-contiguous"):
             fiber_io.read(path)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_io/test_sintela/test_protobuf.py` around lines 704 - 724, Update
test_read_rejects_more_samples_than_endpoints_imply to explicitly verify that
_get_endpoint_metadata returns the endpoint shortcut before asserting the
InvalidFiberFileError. Follow the established pattern in
test_short_final_packet_is_measured_from_the_last_endpoint, ensuring the test
fails if the shortcut is not engaged while preserving the existing overrun-error
assertion through decode_stream.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@benchmarks/test_io_benchmarks.py`:
- Around line 43-46: Update the single_file_path fixture to depend on the shared
test_file_paths fixture and select request.param from that fixture, removing the
direct get_test_file_paths() call while preserving the existing session-scoped
parametrization.

---

Nitpick comments:
In `@tests/test_io/test_sintela/test_protobuf.py`:
- Around line 1232-1249: Move the duplicated _Buf helper from the test-local
scope to module scope beside _CountingReader, preserving its current seek, tell,
and read behavior. Remove the second definition in
test_find_last_record_returns_none_without_framing and update both tests to
reuse the shared _Buf class.
- Around line 704-724: Update
test_read_rejects_more_samples_than_endpoints_imply to explicitly verify that
_get_endpoint_metadata returns the endpoint shortcut before asserting the
InvalidFiberFileError. Follow the established pattern in
test_short_final_packet_is_measured_from_the_last_endpoint, ensuring the test
fails if the shortcut is not engaged while preserving the existing overrun-error
assertion through decode_stream.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 687c4436-8fa1-4360-92fe-553f4e83d9ce

📥 Commits

Reviewing files that changed from the base of the PR and between 29472a3 and 342b904.

📒 Files selected for processing (5)
  • benchmarks/test_io_benchmarks.py
  • dascore/io/sintela/protobuf_utils.py
  • docs/changelog.qmd
  • tests/test_io/test_common_io.py
  • tests/test_io/test_sintela/test_protobuf.py

Comment thread benchmarks/test_io_benchmarks.py Outdated
Comment on lines +43 to +46
@pytest.fixture(scope="session", params=SINGLE_FILE_BENCHMARKS)
def single_file_path(request):
"""Path to one registry file, parametrized for per-format benchmarks."""
return get_test_file_paths()[request.param]

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.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Reuse the shared registry-path fixture.

Each session-scoped parameter instance calls get_test_file_paths(). This resolves the complete registry four times. Depend on test_file_paths and select the requested path from it.

Proposed fix
 `@pytest.fixture`(scope="session", params=SINGLE_FILE_BENCHMARKS)
-def single_file_path(request):
+def single_file_path(request, test_file_paths):
     """Path to one registry file, parametrized for per-format benchmarks."""
-    return get_test_file_paths()[request.param]
+    return test_file_paths[request.param]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@pytest.fixture(scope="session", params=SINGLE_FILE_BENCHMARKS)
def single_file_path(request):
"""Path to one registry file, parametrized for per-format benchmarks."""
return get_test_file_paths()[request.param]
`@pytest.fixture`(scope="session", params=SINGLE_FILE_BENCHMARKS)
def single_file_path(request, test_file_paths):
"""Path to one registry file, parametrized for per-format benchmarks."""
return test_file_paths[request.param]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/test_io_benchmarks.py` around lines 43 - 46, Update the
single_file_path fixture to depend on the shared test_file_paths fixture and
select request.param from that fixture, removing the direct
get_test_file_paths() call while preserving the existing session-scoped
parametrization.

Scanning read every packet's samples to reach its headers, so indexing a
directory cost a full sequential read of every file in it. On a 10,308 file
archive of 505 MB recordings that is 5.9 hours.

A timeseries scan now derives its summary from the first and last packets
alone: sample_count numbers the samples preceding each packet, so the two
endpoints give the total directly. The shortcut is taken only when the derived
length fits the file's bytes and the endpoint timestamps match the span those
samples imply, so a concatenated file, a reset counter, or a misidentified
final record falls back to reading everything. Measured over that archive,
dc.scan goes from 2.05 s to 0.02 s per file, reading 1.3 MB instead of 505 MB.

BAND and FFT scans need every packet's timestamp and so still visit every
record, but are handed each packet's header without its samples.

Reading a timeseries recording streams packets into the output array instead
of decoding all of them first, cutting peak memory from ~2.0x the patch to
~1.02x.

A scan no longer detects a gap between the endpoints when the timestamps
corroborate it, and reports the span they imply; read validates every packet
and still raises InvalidFiberFileError.

Adds a generic guard in test_common_io.py holding every FiberIO to reading
less than a quarter of any file over 1 MB during scan, so this class of
regression cannot recur unnoticed in another reader.
@d-chambers
d-chambers force-pushed the sintela-protobuf-scan-perf branch from 342b904 to dea1778 Compare August 11, 2026 15:33
@coderabbitai coderabbitai Bot removed the benchmark Run the benchmark suite label Aug 11, 2026
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (29472a3) to head (dea1778).

Additional details and impacted files
@@            Coverage Diff             @@
##               dev      #865    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          176       176            
  Lines        19313     19501   +188     
==========================================
+ Hits         19313     19501   +188     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@codspeed-hq

codspeed-hq Bot commented Aug 11, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 79 untouched benchmarks


Comparing sintela-protobuf-scan-perf (dea1778) with dev (29472a3)

Open in CodSpeed

@d-chambers
d-chambers merged commit 1f4542c into dev Aug 11, 2026
28 of 30 checks passed
@d-chambers
d-chambers deleted the sintela-protobuf-scan-perf branch August 11, 2026 15:58
d-chambers added a commit that referenced this pull request Aug 11, 2026
Dev's protobuf scan work (#865) landed after this branch renamed the
vendor identification attrs, so its trailing-META test asserted the
pre-rename spellings; the reader itself already emits the dotted names.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

IO Work for reading/writing different formats patch related to Patch class

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant