Repository navigation
feat(ingest): cut per-record work on the OTLP logs ingest path - #3509
Conversation
Implement the ADR-3492 decisions for the logs ingest path. D1: the flush builds its RLOG writer with PageChoice::Estimated, so each integer and string page's encoding is chosen from statistics instead of sealing every candidate. Every other RLOG writer keeps Exhaustive. The clustering-key flush tests' reference writer moves to Estimated with it, since they compare the flush's object bytes. D2: the flush checks the buffered rows' stream attributes the way the row writer's finish() does, builds one ColumnarLogBatch from them and hands it to push_columnar. A test pins that the object is byte-identical to pushing the rows one at a time, for both page choices, with and without a clustering sort. The measurement D2 required is the logseg_encode bench in #3500 (#3494): on the benchmark shape the columnar arm, including try_from_records per 2,000-record batch, cost 4.5 to 8.6% less CPU per record than the row writer in all four comparisons. D3a: NormalizedLogRecord.stream_attrs is an Arc<[u8]> built once per OTLP scope and shared by its records, so normalization allocates 1 time per record instead of 2. The struct-literal sites in ravel-cli, ravel-bench, ravel-failure-tests and ravel-server tests follow the type; the CLI load path copies the blob once per record. The wide-stream label-charge fixture now gives each record its own resource, because a shared blob no longer allocates in proportion to the projected charge the test separates from it. D5: log stream admission decides each distinct stream id once per request and replays that outcome for its later records; per-record outcomes, the admitted and rejected counters and the epoch set are unchanged, which a property test checks against per-record admission over interleaved and recurring streams and caps around the active count. The router computes each record's byte estimate once and carries it to the shard in a ShardRecords buffer, which merge_rows_unstamped charges instead of recomputing. D6: the age-check tick is IngestConfig::effective_flush_tick(), min(flush_tick, max(max_flush_delay / 4, 25 ms)). It is 200 ms at the default 2 s delay and 25 ms at a 100 ms delay, where the idle flush hold grows by 175 ms. The idle hold, the flush trigger age bound, the shard tick loops and the server's deferral-cap startup validation all use it, and the startup refusal states the bound. docs/consistency-model.md, docs/ingest.md and the cost-model guide now name the effective tick where they named flush_tick. Known and unchanged: the ingest byte budget and the resolved-label projection still charge stream_attrs per record, so a shared blob is over-charged, never under-charged. Fixes: #3498 Refs: #3492 Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
|
@claude-fleet review |
|
Review task queued. Task: 518a25f9-b493-40d7-b9e2-f7f94a910bb0 |
There was a problem hiding this comment.
Review ready.
PR #3509 does what its description says: the OTLP logs ingest path now shares one stream_attrs blob per scope, admits once per stream id instead of once per record, carries each record's byte estimate from the router to its shard, flushes through push_columnar with PageChoice::Estimated, and derives the flush tick from max_flush_delay. I read every changed file and the unchanged code the changes depend on (ravel-logseg writer and ColumnarLogBatch::try_from_records, EpochIdSet::admit, the server's deferral-cap validation). One doc paragraph is now wrong, and it is normative.
Findings:
-
docs/log-segment-format.md:725still states the pre-ADR-3492 wiring. It says the logs ingest flush "selectsEstimatedonce ADR-3492 decision 1 is wired into it, and writesExhaustiveuntil then", and thatravel-cli loadwritesExhaustive. After this PR both halves are false:flush_rlog_configsetsPageChoice::Estimated, andservices/ravel-cli/src/load/logs.rs:493builds aLogIngestRouter, soravel-cli loadwrites through the same flush and is nowEstimatedtoo. This is the format document's own "which writers use which" table-in-prose, and the repo rule is that the doc moves in the same commit as the behavior.docs/ingest.md,docs/consistency-model.mdanddocs/guides/cost-model.mdwere updated; this one was missed. -
crates/ravel-ingest/src/log_shard.rs:429,to_logseg_recorddoesstream_attrs: rec.stream_attrs.to_vec(). D3a removes one blob copy per record at normalization, and this adds one back per record at flush. The saving is real for buffered memory (a 10k-record buffer holds oneArc<[u8]>per record instead of 10k copies) and real for records dropped by admission or by a validation error, but for a record that reaches a flush the end-to-end allocation count per record is unchanged. Worth either taking&Arc<[u8]>through to the batch builder ifColumnarLogBatchcan hold a borrowed blob, or stating in the commit message that the win is buffer residency rather than total allocations, so the #3500 figures are read correctly.
Verified:
- Admission equivalence.
admit_batch_per_idcaches one decision per stream id and replays it, so the per-id and per-record forms can only differ if a repeat occurrence of an id could get a different answer.EpochIdSet::admit(admission.rs:333-349) only growsactive_countand inserts an admitted id intocurrent, so a second occurrence of an admitted id hits the membership fast path and a second occurrence of a rejected id sees a count that has not shrunk. The new proptest compares full post-state tuples, not just the returned set, soseries_admitted_totalandseries_rejected_cap_totalare pinned too. - The estimate carried from the router is the one the budget charged:
log_router.rscomputesrecord_bytesonce, folds it intoestimate, and zips the same vector intoShardRecords::push. Nothing mutates the records between those two points. check_stream_attrsmatches the row writer. It builds the first blob per stream id in push order, same asbuild_object'sBTreeMapdirectory pass (ravel-logseg/src/writer.rs:480-525), and raises the identicalLogSegError::InconsistentStreamAttrsmessage; the new test asserts the flush message equals whatfinish()produces, so a message change on either side breaks it.- Byte identity between the row and columnar paths at ingest shape is already covered independently by
ravel-logseg/tests/differential.rs:983-1017(estimated_mode_is_deterministic), andthe_columnar_flush_writes_the_row_writers_bytesadds the ingest-side case under both page choices with and without the clustering sort. effective_flush_tickhas no remaining bypass: no use of.flush_ticksurvives outsideconfig.rs. The server refusal text's hardcoded "200 ms" is accurate becauseflush_tickhas no CLI flag.- The allocation test's tightening is the real guard here:
FLOOR_PER_RECORDdrops 2 to 1 with a strictassert_eq!(many - few, FLOOR_PER_RECORD * (MANY - FEW)), which fails if the blob is ever copied per record again.
Checks run:
cargo test -p ravel-ingest --lib -- admission::tests config::tests log_shard::tests: ok, 98 passed, 0 failed.- Fix-proof for the page-choice change: with
flush_rlog_configreverted toPageChoice::Exhaustive,the_flush_writer_estimates_page_encodingsFAILED atlog_shard.rs:3383andthe_columnar_flush_writes_the_row_writers_bytesstill passed (it asserts row/columnar parity under whichever choice is configured, so it is not the guard for D1). Source restored; working tree is clean.
Smaller things:
- Flush peak memory grows:
flush_rows_batchmaterializes a fullVec<LogRecord>and then aColumnarLogBatchwhile the shard's own buffer is still live. For a buffer at the byte budget that is roughly three copies in flight where the row path streamed one record at a time. Probably fine at current budgets, but it is the opposite direction from the rest of the PR. services/ravel-server/src/lib.rshardcodes "200 ms" and "25 ms" in the refusal text and repeats them inflush_tick_bound_tests, so a change toFLUSH_TICK_FLOORleaves a message that compiles, passes and lies. Formatting the floor from the constant would make the test pin the constant instead of a transcription of it.services/ravel-cli/src/load/cli.rs:656describes the flush tick in terms that no longer hold now that it is derived.FLUSH_TICK_FLOORispubbut has no use outsideconfig.rsand its own tests.
Not checked:
- The workspace suite and the feature lanes (
sql,flight-sql,esql, agents, mcp). I ran only the threeravel-ingestlib test modules plus the targeted revert run. ravel-otlp,ravel-server,ravel-cliandravel-failure-testssuites, includinglogs_normalize_allocations.rsandlabel_charge_allocations.rs, whose assertions I read but did not execute.- The
logseg_encodebenchmark figures the description cites from #3500. I did not reproduce them and cannot say the quoted per-line improvement holds on this tree. - Behavior under a real object store, and any effect of the shorter tick on flush rate at production record sizes.
Not checked:
- Workspace suite and the sql, flight-sql, esql, agents and mcp feature lanes; I ran only the ravel-ingest lib tests for admission, config and log_shard plus one targeted revert run.
- ravel-otlp, ravel-server, ravel-cli and ravel-failure-tests suites, including logs_normalize_allocations.rs and label_charge_allocations.rs, whose assertions I read but did not execute.
- The logseg_encode benchmark figures from #3500 quoted in the description; not reproduced on this tree.
- Behavior against a real object store, and the effect of the shorter effective flush tick on flush rate and object count at production record sizes.
Findings outside the diff:
- docs/log-segment-format.md:725 (should_fix): This paragraph still describes the pre-ADR-3492 wiring and is now wrong in both halves.
flush_rlog_configincrates/ravel-ingest/src/log_shard.rssetsPageChoice::Estimatedin this PR, so the ingest flush no longer "writesExhaustiveuntil then"; andservices/ravel-cli/src/load/logs.rs:493builds aLogIngestRouter, soravel-cli loadgoes through that same flush and is no longer anExhaustivewriter either. This is the format document's own statement of which writers use which page choice, so a reader checking what produced an object gets the wrong answer.docs/ingest.md,docs/consistency-model.mdanddocs/guides/cost-model.mdwere updated in this PR; this one needs the same treatment.
Task 518a25f9-b493-40d7-b9e2-f7f94a910bb0 on ci-16gb-fsn1-1, model claude-opus-5, effort medium.
| LogRecord { | ||
| stream_id: rec.stream_id, | ||
| stream_attrs: rec.stream_attrs, | ||
| stream_attrs: rec.stream_attrs.to_vec(), |
There was a problem hiding this comment.
nit: stream_attrs.to_vec() copies the shared blob once per record at flush, which is the allocation D3a just removed from normalization. The change still wins on buffered memory (one Arc<[u8]> per buffered record instead of one owned Vec each) and on records dropped by admission or a validation error before any flush, but for a record that reaches a flush the end-to-end allocation count per record is unchanged. If ColumnarLogBatch::try_from_records can be given a borrowed or Arc-held blob, threading &Arc<[u8]> through would make the saving end-to-end. Otherwise it is worth saying in the commit message that the win is buffer residency, so the #3500 per-line figures are not read as an allocation-count claim.
The ingest flush always built its RLOG writer with PageChoice::Estimated, and ravel-cli load writes through the same log ingest router and shard flush, so the bulk loader lost the exhaustive page choice ADR-3492 decision 1 and the ADR-2135 amendment keep for it. load_zstd_level saw 7 zstd pages where the exhaustive writer stores 13. The page choice is now IngestConfig::rlog_page_choice. It defaults to Estimated, which the server's logs ingest flush picks up through IngestConfig::default(), and the log loader's router config sets Exhaustive. A ravel-ingest test pins that the flush writer follows the setting, and a ravel-cli test pins that the loader's config asks for Exhaustive while the default it builds on estimates. Refs: #3498 Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
|
@claude-fleet review |
|
Review task queued. Task: a8cd10d7-f2f3-4f07-bd32-92453c78a049 |
There was a problem hiding this comment.
Previous round at 8038ebc: 2 findings.
| Previous finding | Classification | Cited line |
|---|---|---|
docs/log-segment-format.md:725 (should_fix): This paragraph still describes the pre-ADR-3492 wiring and is now wrong in both halves. flush_rlog_config in crates/ravel-ingest/src/log_shard.rs sets PageChoice::Estimated in this PR, so the ingest flush no longer "writes Exhaustive until then"; and services/ravel-cli/src/load/logs.rs:493 builds a LogIngestRouter, so ravel-cli load goes through that same flush and is no longer an Exhaustive writer either. This is the format document's own statement of which writers use which page choice, so a reader checking what produced an object gets the wrong answer. docs/ingest.md, docs/consistency-model.md and docs/guides/cost-model.md were updated in this PR; this one needs the same treatment. |
open, not reposted: docs/log-segment-format.md:725 is not in this diff | docs/log-segment-format.md:725 |
crates/ravel-ingest/src/log_shard.rs:429 (nit): stream_attrs.to_vec() copies the shared blob once per record at flush, which is the allocation D3a just removed from normalization. The change still wins on buffered memory (one Arc<[u8]> per buffered record instead of one owned Vec each) and on records dropped by admission or a validation error before any flush, but for a record that reaches a flush the end-to-end allocation count per record is unchanged. If ColumnarLogBatch::try_from_records can be given a borrowed or Arc-held blob, threading &Arc<[u8]> through would make the saving end-to-end. Otherwise it is worth saying in the commit message that the win is buffer residency, so the #3500 per-line figures are not read as an allocation-count claim. |
open | crates/ravel-ingest/src/log_shard.rs:429 |
Request changes: the previous should_fix doc finding is half fixed and still stands.
The new commit resolves the ravel-cli load half of it by giving IngestConfig an rlog_page_choice lever that the loader sets to Exhaustive (services/ravel-cli/src/load/logs.rs:2087-2100, crates/ravel-ingest/src/config.rs:821-826), with a unit test on each side. docs/log-segment-format.md:725-727 is untouched, so the format document still says the logs ingest flush "writes Exhaustive until then", which this PR makes false.
Findings:
- should_fix, docs/log-segment-format.md:725 — still says the ingest flush writes
Exhaustiveuntil decision 1 is wired in (previous finding 1, now only the first half) - nit, crates/ravel-ingest/src/log_shard.rs:429 — the flush still copies the shared blob per record (previous finding 2, unchanged)
Verified:
- D1:
flush_rlog_configreads the config lever, and the loader is the only non-default caller — crates/ravel-ingest/src/log_shard.rs:409-419, services/ravel-cli/src/load/logs.rs:493-500;build_ingest_confighas one log router call site. - D2 equivalence:
check_stream_attrsreproduces the row writer's check exactly — same first-blob-per-id-in-push-order rule and the same message as crates/ravel-logseg/src/writer.rs:499-515. The byte-identity claim rests on the crate's own row/columnar invariant (crates/ravel-logseg/src/writer.rs:413-415) and its property test at writer.rs:7032, not only on the new 48-record fixture. - D2 error mapping: a collision still reaches
LogWriteError::StreamIdCollisionandrecord_stream_id_collision, becauseflush_rows_batchwraps it inRlogEncodeError::Finish— crates/ravel-ingest/src/log_shard.rs:463 and 1073-1077. - D5 equivalence: within one batch
active_countonly grows and an admitted id is incurrent, so a replayed outcome cannot differ — crates/ravel-ingest/src/admission.rs:333-349 against 1083-1104. - D5 estimate carrying: the per-record estimates are zipped onto the same unfiltered
recordsvec, nothing between the two reorders or drops a record — crates/ravel-ingest/src/log_router.rs:587-617;merge_rows_unstampedis all-or-nothing, so a carried sum is never charged for records that did not land (log_shard.rs:628-656). - D6: no
config.flush_tickread remains outsideeffective_flush_tickand tests; the server's refusal text is accurate becausevalidate_flush_deferral_capalways builds on the default 200 ms tick — services/ravel-server/src/lib.rs:2274-2290. - D6 durability: hold + tick still equals
max_flush_lifetimeat any delay, soFLUSH_BOUND_SLACK_HOURSkeeps its derivation — crates/ravel-ingest/src/config.rs:505-512. - D3a: one
Arc<[u8]>perScopeLogs, asserted byArc::ptr_eq— crates/ravel-otlp/src/logs_normalize.rs:443-444, 1435-1439.
Smaller things:
- docs/ingest.md:1023 now states the flush configures
PageChoice::Estimatedwithout the loader exception the changelog does mention. Same drift class as the finding above; worth one clause. crates/ravel-catalog/src/provisioning.rs:441-476still writes "oneflush_tick" in the slack derivation. The derivation stays correct under the effective tick, so this is only a stale term, but it is the same wording the PR updated everywhere in ravel-ingest.rows_to_batch(the hand-back conversion, log_shard.rs:445-452) keeps the first blob per stream with nocheck_stream_attrs, so the new flush path and the hand-back path now disagree on a diverging blob. Pre-existing and I did not find a reachable input, so no finding.wide_stream_log_requestmoving to one resource per record (services/ravel-server/tests/label_charge_allocations.rs:123-141) means the common OTLP shape, many records under one scope, is no longer the shape that test measures. The direction is safe (over-charge), and the PR says so.
Not checked:
- Nothing was compiled or run here: no
cargo check, clippy or test run, so theIngestConfigPartialEqcomparison added in services/ravel-cli/src/load/logs/tests.rs:5183-5190 and the new tests are unverified beyond reading. - The #3500 / #3494 CPU figures quoted for D2 are outside this repo state.
- Mechanical
.into()/.to_vec()churn across the 20-oddtests/files was skimmed, not read line by line.
Not checked:
- No build, clippy or test run was performed in this workspace; the new tests and the IngestConfig PartialEq comparison in services/ravel-cli/src/load/logs/tests.rs were read, not executed.
- The #3500/#3494 CPU measurements quoted for D2.
- Line-by-line reading of the mechanical .into()/.to_vec() churn in the ~20 ravel-ingest and ravel-cli test files.
Task a8cd10d7-f2f3-4f07-bd32-92453c78a049 on rp2, model claude-opus-5, effort medium.
| LogRecord { | ||
| stream_id: rec.stream_id, | ||
| stream_attrs: rec.stream_attrs, | ||
| stream_attrs: rec.stream_attrs.to_vec(), |
There was a problem hiding this comment.
nit: Still open since 8038ebc. stream_attrs.to_vec() copies the shared blob once per record at flush, which is the allocation D3a just removed from normalization. The change still wins on buffered memory (one Arc<[u8]> per buffered record instead of one owned Vec each) and on records dropped by admission or a validation error before any flush, but for a record that reaches a flush the end-to-end allocation count per record is unchanged. If ColumnarLogBatch::try_from_records can be given a borrowed or Arc-held blob, threading &Arc<[u8]> through would make the saving end-to-end. Otherwise it is worth saying in the commit message that the win is buffer residency, so the #3500 per-line figures are not read as an allocation-count claim.
| - Objects are RLOG, built with `ravel_logseg::RlogWriter`, not RSEG built | ||
| with `SegmentWriter`. They land under the `l` keyspace | ||
| with `SegmentWriter`. The flush configures the writer with | ||
| `PageChoice::Estimated` (ADR-3492 decision 1), and hands a row-major buffer |
There was a problem hiding this comment.
nit: This sentence says the logs flush configures the writer with PageChoice::Estimated without the exception the same PR adds: ravel-cli load drives this same flush with PageChoice::Exhaustive.
Failure: a reader checking what wrote a loader-produced object concludes its pages were estimated.
Evidence: services/ravel-cli/src/load/logs.rs:2087-2100 sets rlog_page_choice: PageChoice::Exhaustive for the loader's router, and crates/ravel-ingest/src/log_shard.rs:415 passes it straight to the writer; changelog.d/3498.changed.md:3 does carry the exception.
The format doc still said the logs ingest flush writes Exhaustive until ADR-3492 decision 1 is wired in. It now is: the server's logs ingest flush writes Estimated through IngestConfig::rlog_page_choice, ravel-cli load writes through the same flush with Exhaustive, and compaction and the erasure rewrite stay Exhaustive. Refs: #3498 Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
|
@claude-fleet review |
|
Review task queued. Task: ffa373c0-1e20-4c70-a269-6440e9af2034 |
There was a problem hiding this comment.
Previous round at e6f2184: 3 findings.
| Previous finding | Classification | Cited line |
|---|---|---|
docs/log-segment-format.md:725 (should_fix): This paragraph still describes the pre-ADR-3492 wiring and is now wrong in both halves. flush_rlog_config in crates/ravel-ingest/src/log_shard.rs sets PageChoice::Estimated in this PR, so the ingest flush no longer "writes Exhaustive until then"; and services/ravel-cli/src/load/logs.rs:493 builds a LogIngestRouter, so ravel-cli load goes through that same flush and is no longer an Exhaustive writer either. This is the format document's own statement of which writers use which page choice, so a reader checking what produced an object gets the wrong answer. docs/ingest.md, docs/consistency-model.md and docs/guides/cost-model.md were updated in this PR; this one needs the same treatment. |
not classified | none |
crates/ravel-ingest/src/log_shard.rs:429 (nit): stream_attrs.to_vec() copies the shared blob once per record at flush, which is the allocation D3a just removed from normalization. The change still wins on buffered memory (one Arc<[u8]> per buffered record instead of one owned Vec each) and on records dropped by admission or a validation error before any flush, but for a record that reaches a flush the end-to-end allocation count per record is unchanged. If ColumnarLogBatch::try_from_records can be given a borrowed or Arc-held blob, threading &Arc<[u8]> through would make the saving end-to-end. Otherwise it is worth saying in the commit message that the win is buffer residency, so the #3500 per-line figures are not read as an allocation-count claim. |
not classified | none |
docs/ingest.md:1023 (nit): This sentence says the logs flush configures the writer with PageChoice::Estimated without the exception the same PR adds: ravel-cli load drives this same flush with PageChoice::Exhaustive. Failure: a reader checking what wrote a loader-produced object concludes its pages were estimated. Evidence: services/ravel-cli/src/load/logs.rs:2087-2100 sets rlog_page_choice: PageChoice::Exhaustive for the loader's router, and crates/ravel-ingest/src/log_shard.rs:415 passes it straight to the writer; changelog.d/3498.changed.md:3 does carry the exception. |
not classified | none |
Not approved: a finding from the previous round is not fixed and is not a nit.
No findings.
Verdict
Approve. One of the three previous findings is fixed; the two that stand are both nits (a doc omission and a commit-message overclaim), neither blocking. No new blocking or should_fix issue survived this pass.
Previous findings
1 — fixed. docs/log-segment-format.md:725 now names the writers: the server's logs ingest flush writes Estimated via the rlog_page_choice setting, ravel-cli load writes Exhaustive through the same flush, and compaction plus the erasure rewrite write Exhaustive. That is the whole of the change since the previous round (commit 3062625), and it says exactly what the finding asked for.
2 — open (nit). crates/ravel-ingest/src/log_shard.rs:429: to_logseg_record still does stream_attrs: rec.stream_attrs.to_vec(), so the shared Arc<[u8]> is copied once per record when the flush builds the columnar batch. The saving D3a buys is buffer residency, not total allocations across the record's life. The PR description now carries that caveat; the commit message of 8038ebc still reads "normalization allocates 1 time per record instead of 2" with no qualifier, and that message is what survives in git log.
3 — open (nit). docs/ingest.md:1023 still says the flush configures the writer with PageChoice::Estimated without noting that ravel-cli load drives the same flush with Exhaustive (services/ravel-cli/src/load/logs.rs, log_load_ingest_config). changelog.d/3498.changed.md:3 does carry the exception, and docs/log-segment-format.md:725 now does too, so docs/ingest.md is the one place left where a reader meets the flush and is told there is only one page choice.
Verified
check_stream_attrsreproduces the row writer's collision rule.RlogWriter's row-path directory check (writer.rs:494-512) walksself.recordsin push order and keeps the first blob seen per stream id;check_stream_attrsdoes the same with aHashMap<LogStreamId, &Arc<[u8]>>, withArc::ptr_eqonly as a fast path before the byte comparison, so a secondArcholding equal bytes is still accepted and a differing one still refused. The error travels asRlogEncodeError::Finish(LogSegError::InconsistentStreamAttrs)and reaches the sameLogWriteError::StreamIdCollisionandrecord_stream_id_collision()at log_shard.rs:1073 the row path used.- Byte identity for D2. The flush builds
ColumnarLogBatch::try_from_recordswith no.with_dictionaries(), which is the same shapecolumnar_build_matches_row_build_byte_for_byteanddictionary_columns_match_row_build_byte_for_bytealready pin in ravel-logseg, includingmax_dyn in 1..8so themax_dynamic_columnsoverflow path is covered on both sides.buildapplies that budget identically for row and columnar input. admit_batch_per_idis equivalent to per-record admission, not just cheaper.EpochIdSet::admitreturns true forever once an id is incurrent, andcap.admits_one_more(active_count)only gets stricter within a batch becauseactive_countonly grows; a rejected id is never inserted, so a first-occurrence reject stays a reject for every later occurrence. One entry is still pushed per candidate and the counters still move per candidate, so the shed accounting is unchanged. The new proptest compares full post-state tuples, not just the verdict vector.effective_flush_tickcan only shorten the tick (minagainst the existingflush_tick), so no test or deployment that sets a smallflush_tickchanges behaviour, andflush_tickhas no CLI flag, so nothing a user set is silently overridden. The millennium-tick config test now asserts real numbers (40_500_000_000/3_559_500_000_000) where it used to assert thei64::MAX/0saturation, which is the point of D6.- The carried byte estimate is the same number the budget charged.
log_router.rscomputesrecord_bytesonce, folds it into the charge, and zips it into each shard'sShardRecords; nothing between those two points mutates the records.merge_rows_unstampedtakesbytes_addedfrom the struct instead of recomputing it, andimpl From<Vec<NormalizedLogRecord>>recomputes via the sameest_record_bytesfor the paths that still build a bareVec. - Sharing the stream blob is safe: no production path mutates
stream_attrs, and the normalizer builds one blob perScopeLogsandArc::clones it, which theArc::ptr_eqtest pins.
Checks run
cargo test -p ravel-ingest --lib: 397 passed, 0 failed. All eight new tests execute and pass, includingthe_columnar_flush_writes_the_row_writers_bytesacross both page choices and both sort settings.- Revert check: deleting the
check_stream_attrs(&records).map_err(RlogEncodeError::Finish)?line makesthe_flush_batch_refuses_diverging_stream_attrs_as_the_row_writer_doesfail at log_shard.rs:3547 ("the flush batch refuses the records from finish"). The test earns its place. File restored;git status --porcelainshows only the untrackednode_modules.
Smaller things
services/ravel-server/tests/label_charge_allocations.rs:wide_stream_log_request()changed from one shared resource with UNITS records to UNITS resources with one record each, because the projection charges per record while normalization now allocates one blob per scope. That keeps the test meaningful, but it means the realistic shape (many records under one resource) is no longer covered by an allocation assertion. The divergence is in the safe direction, since the projection over-charges and so sheds earlier than real memory warrants, and the PR says so. Worth a sentence in the test's module doc so the next reader does not restore the old fixture and find it failing.FLOOR_PER_RECORDdropping 2 to 1 incrates/ravel-otlp/tests/logs_normalize_allocations.rs:41is the measurement that backs D3a, and the module doc at lines 9-14 already explains why the per-scope allocation cancels out of the two-size difference. Nothing to change; noting it as the place a future regression would surface.
Not checked
- I did not run the ravel-server or ravel-cli test suites, nor any feature lane (
sql,flight-sql,esql,agents,mcp), nor clippy or fmt. CI covers these. - I did not open the mechanical
.into()/.to_vec()fixture edits spread across the ravel-ingest tests, ravel-bench, ravel-failure-tests, and the ravel-cli export and test-support files. They follow from theShardRecordsandArc<[u8]>type changes and the crate compiles and tests green, but I did not read each one. - I did not reproduce the logseg_encode CPU figure the description cites from #3500 / #3494, so the claimed size of the win is unverified here.
Not checked:
- The ravel-server and ravel-cli test suites, and every feature lane (sql, flight-sql, esql, agents, mcp), plus clippy and fmt: not run, left to CI.
- The mechanical .into() / .to_vec() fixture edits across the ravel-ingest tests, ravel-bench, ravel-failure-tests, and the ravel-cli export and test-support files: not read individually.
- The logseg_encode CPU figure cited from #3500 / #3494: not reproduced, so the claimed size of the win is unverified.
Task ffa373c0-1e20-4c70-a269-6440e9af2034 on ravel-clickbench-ubuntu-2, model claude-opus-5, effort medium.
Implement the ADR-3492 decisions for the logs ingest path.
D1: the server's logs ingest flush builds its RLOG writer with
PageChoice::Estimated, so each integer and string page's encoding is
chosen from statistics instead of sealing every candidate. The choice is
IngestConfig::rlog_page_choice (default Estimated); the bulk loader, which
writes through the same flush, sets Exhaustive, and every other RLOG writer
keeps Exhaustive. The
clustering-key flush tests' reference writer moves to Estimated with it,
since they compare the flush's object bytes.
D2: the flush checks the buffered rows' stream attributes the way the row
writer's finish() does, builds one ColumnarLogBatch from them and hands it
to push_columnar. A test pins that the object is byte-identical to pushing
the rows one at a time, for both page choices, with and without a
clustering sort. The measurement D2 required is the logseg_encode bench in
#3500 (#3494): on the benchmark shape the columnar arm, including
try_from_records per 2,000-record batch, cost 4.5 to 8.6% less CPU per
record than the row writer in all four comparisons.
D3a: NormalizedLogRecord.stream_attrs is an Arc<[u8]> built once per OTLP
scope and shared by its records, so normalization allocates 1 time per
record instead of 2. The flush still copies the blob once per record into
the columnar batch, so the end-to-end saving is buffered memory (one shared
blob per scope instead of one owned copy per buffered record), not the
allocation count of a record that reaches a flush. The struct-literal sites in ravel-cli, ravel-bench,
ravel-failure-tests and ravel-server tests follow the type; the CLI load
path copies the blob once per record. The wide-stream label-charge fixture
now gives each record its own resource, because a shared blob no longer
allocates in proportion to the projected charge the test separates from
it.
D5: log stream admission decides each distinct stream id once per request
and replays that outcome for its later records; per-record outcomes, the
admitted and rejected counters and the epoch set are unchanged, which a
property test checks against per-record admission over interleaved and
recurring streams and caps around the active count.
The router computes each record's byte estimate once and carries it to the
shard in a ShardRecords buffer, which merge_rows_unstamped charges instead
of recomputing.
D6: the age-check tick is IngestConfig::effective_flush_tick(),
min(flush_tick, max(max_flush_delay / 4, 25 ms)). It is 200 ms at the
default 2 s delay and 25 ms at a 100 ms delay, where the idle flush hold
grows by 175 ms. The idle hold, the flush trigger age bound, the shard tick
loops and the server's deferral-cap startup validation all use it, and the
startup refusal states the bound. docs/consistency-model.md, docs/ingest.md
and the cost-model guide now name the effective tick where they named
flush_tick.
Known and unchanged: the ingest byte budget and the resolved-label
projection still charge stream_attrs per record, so a shared blob is
over-charged, never under-charged.
Fixes: #3498
Refs: #3492