Skip to content

perf: batch time-series HTTP ingest into one append transaction per measurement#5419

Open
robfrank wants to merge 2 commits into
mainfrom
fix/ts-http-ingest-batch-append
Open

perf: batch time-series HTTP ingest into one append transaction per measurement#5419
robfrank wants to merge 2 commits into
mainfrom
fix/ts-http-ingest-batch-append

Conversation

@robfrank

@robfrank robfrank commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Batches time-series HTTP ingest so each measurement's samples are appended in one shard transaction instead of one per sample.

  • PostTimeSeriesWriteHandler (InfluxDB line protocol) groups parsed samples by measurement, then issues one TimeSeriesEngine.appendBatch per group. The schema lookup and instanceof narrowing now happen once per measurement rather than once per sample.
  • PostPrometheusWriteHandler issues one appendBatch per remote-write TimeSeries — those samples already share a type and label set.

TimeSeriesEngine.appendBatch already existed for exactly this and was only reachable from tests.

Behaviour is otherwise unchanged: drop-set ordering, the written/dropped counts and the partial-write 400 response are preserved (LinkedHashMap/LinkedHashSet keep first-occurrence order).

Motivation

Both handlers called TimeSeriesEngine.appendSamples once per parsed sample. Each of those opens its own begin/commit inside TimeSeriesShard.appendSamples, and on a Raft HA leader that commit is a full replicated quorum round trip — so a 100-sample request cost 100 sequential quorum round trips, all serialized behind the per-shard appendLock.

TimeSeriesThreeNodeWalGapReproIT was failing on this. Evidence gathered while reproducing:

  • Thread dump: 3 of 4 XNIO worker threads parked on appendLock (TimeSeriesShard.java:196); the fourth inside RaftGroupCommitter.submitAndWait, reached from appendSamplesRaftReplicatedDatabase.commit.
  • Measured ingest rate on a 3-node localhost cluster with ~19 ms inter-node RTT: ~18 samples/sec (row count 3117 → 3488 over 20 s), i.e. ~11 minutes for the test's 12,000 samples against a 3-minute writer budget.
  • The continuous compactAll() loop compounded it — every append commit waits out the active compaction recording session via waitForActiveRecordingSession — but the per-sample round trip alone already exceeded the budget.
before after
TimeSeriesThreeNodeWalGapReproIT fails after 191.8 s passes in 19.1 s
append transactions per 200-sample request 200 ≤ 2

The NullPointerException: ...getTransactionBroker() is null seen in that test's log is downstream, not the cause: the assertion fails, @AfterEach stops the servers, and the still-in-flight HTTP writes then hit a torn-down RaftHAServer.

Related issues

None — found while investigating the TimeSeriesThreeNodeWalGapReproIT failure.

Additional Notes

TimeSeriesIngestBatchAppendIT guards the batching contract for both ingest handlers using the writeTx database statistic. It counts commits that produced WAL, so it counts shard append transactions directly — a deterministic assertion with no timing dependency. Verified to discriminate rather than merely pass: with the per-sample shape temporarily restored it measures 200 transactions in every case.

The line-protocol cases pin SHARDS 1 in DDL and bind to ≤ 2. The remote_write cases cannot: an auto-created type gets ASYNC_WORKER_THREADS shards and appendBatch opens at most one transaction per shard that received samples, so a hardcoded bound would vary with the CPU count of the machine running the test. Those assertions read the shard count back off the type instead.

Left alone deliberately, flagging for a follow-up: RaftHAServer.getTransactionBroker() returning null after stop() surfaces as a raw NPE to in-flight writers rather than a clean "server is shutting down" error. Harmless here now, but it will keep producing confusing stack traces on any shutdown-under-load.

Verification:

Suite Result
TimeSeriesIngestBatchAppendIT (new) 4/4 — fails pre-fix (200 transactions), passes post-fix
server TS / Prometheus / Grafana / PromQL ITs 58/58
engine com.arcadedb.engine.timeseries.*Test 284/284
ha-raft TS ITs (incl. Issue4458TsWalVersionGapIT, RaftTimeSeriesReplication3NodesIT) 6/6
TimeSeriesThreeNodeWalGapReproIT passes, 17.9 s, all 3 nodes converge to 12,000 samples

Checklist

  • I have run the build using mvn clean package command — not run; verified with targeted mvn -pl server / -pl engine / -pl ha-raft builds and the test suites listed above
  • My unit tests cover both failure and success scenarios

…easurement

PostTimeSeriesWriteHandler and PostPrometheusWriteHandler called
TimeSeriesEngine.appendSamples once per parsed sample. Each of those opens its
own begin/commit inside TimeSeriesShard.appendSamples, and on a Raft HA leader
that commit is a full replicated quorum round trip - so a 100-sample request
cost 100 sequential round trips, all serialized behind the per-shard append
lock.

Measured on a 3-node localhost cluster with ~19 ms inter-node RTT, sustained
line protocol ingest ran at ~18 samples/sec. TimeSeriesThreeNodeWalGapReproIT
(12,000 samples) needed roughly 11 minutes and timed out against its 3-minute
writer budget; it now completes in ~19 s.

TimeSeriesEngine.appendBatch already exists for this and was only reachable
from tests. The line protocol handler now groups samples by measurement (so the
schema lookup and instanceof narrowing happen once per measurement rather than
once per sample) and issues one appendBatch per group; the Prometheus handler
issues one per remote-write TimeSeries, whose samples already share a type and
labels. Drop-set ordering, the written/dropped counts and the partial-write 400
response are unchanged - LinkedHashMap and LinkedHashSet preserve
first-occurrence order.

PostTimeSeriesWriteHandlerBatchAppendIT guards the batching contract using the
writeTx database statistic, which counts commits that produced WAL and so
counts shard append transactions directly, giving a deterministic assertion
with no timing dependency. It measures 200 transactions for 200 samples before
this change and at most 2 after.
@mergify

mergify Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@codacy-production

codacy-production Bot commented Jul 25, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Coverage 96.83% diff coverage · -6.32% coverage variation

Metric Results
Coverage variation -6.32% coverage variation
Diff coverage 96.83% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (c885235) 143368 105728 73.75%
Head commit (95f212b) 175651 (+32283) 118436 (+12708) 67.43% (-6.32%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#5419) 63 61 96.83%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@robfrank robfrank added this to the 26.8.1 milestone Jul 25, 2026
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review: batch time-series HTTP ingest

Solid, well-motivated change. It reuses the existing TimeSeriesEngine.appendBatch (previously test-only) instead of inventing new machinery, and the per-measurement grouping with LinkedHashMap/LinkedHashSet correctly preserves the first-occurrence drop-set ordering the client-facing 400 relies on. The PR description, thread-dump evidence, and before/after table are excellent. Both handlers call appendBatch inside an active database.begin(), so appendBatch takes its in-thread sequential per-shard path - the HA-safe one that avoids publishing shard pages out of band with the enclosing commit. Good.

A few points, none blocking:

1. Prometheus handler: hoist tag/label values out of the per-sample loop (perf). In PostPrometheusWriteHandler.java (~L120-133), the labels of a remote-write TimeSeries are per-series, not per-sample, yet findLabelValue(ts.getLabels(), col.getName()) - a linear scan of the label list - is now recomputed for every sample x every TAG column, always returning the same value. Before batching this ran once (one sample); now it runs count times per tag with identical results. Since the value grid is column-major, you can resolve each TAG column value once before the sample loop and just fill the row. Given the project performance mantra this is worth doing. It does NOT apply to the line-protocol handler, where sample.getTags().get(...) genuinely varies per line - keeping that per-sample is correct.

2. Prometheus batching has no regression test asserting the contract (test coverage). The new PostTimeSeriesWriteHandlerBatchAppendIT is a nice deterministic, timing-free assertion via the writeTx statistic - but it only covers the line-protocol handler. The PostPrometheusWriteHandler change is exercised only for correctness by the existing PrometheusRemoteWriteReadIT, which does not assert the batching (transaction-count) contract. A regression there could silently revert Prometheus to per-sample commits without any test failing. Consider an analogous writeTx-delta assertion for a multi-sample remote-write series.

3. Empty remote-write series still auto-creates a type (minor, pre-existing). PostPrometheusWriteHandler calls getOrCreateType(...) before the (count == 0) continue guard, so a TimeSeries carrying labels but zero samples still creates a schema type with no data. This matches pre-change behavior (the old sample loop simply did not execute), so it is not a regression - but if you are touching this path, moving the empty check ahead of getOrCreateType would avoid the surprise.

4. Partial-write window on multi-measurement failure (pre-existing, worth documenting). TimeSeriesShard.appendSamples runs its own begin/commit on getWrappedDatabaseInstance(), so each appendBatch commits at the shard level immediately rather than deferring to the handler outer commit(). If a later measurement appendBatch throws, the handler database.rollback() cannot undo earlier measurements already-committed shard writes, and the client gets a 500 with partial data persisted. This predates the PR (per-sample appendSamples committed independently too), so it is not introduced here - but batching reshapes the granularity (per-measurement rather than per-sample); a line in the code/docs would keep the outer begin/commit from being mistaken for atomicity across measurements.

Minor. Schema lookups (existsType/getType) in PostTimeSeriesWriteHandler now run before database.begin() rather than inside it. Fine for reads, just noting the move.

Overall the correctness reasoning holds and the fix targets a real, well-evidenced HA hot spot. Addressing (1) and (2) would round it out.

…us batching

Review follow-ups on the time-series ingest batching change.

PostPrometheusWriteHandler filled the value grid sample-major, calling
findLabelValue for every TAG column of every sample. Those labels are
per-series, not per-sample, so every call after the first returned the same
value from a linear scan that re-sanitizes each label name it visits. The grid
is column-major, so filling it column by column lets a TAG column resolve once
and broadcast down its column.

TimeSeriesIngestBatchAppendIT (renamed from
PostTimeSeriesWriteHandlerBatchAppendIT) now covers the Prometheus remote_write
handler too, so a regression there cannot silently restore per-sample commits.
The bound is read back from the type rather than hardcoded: an auto-created
remote-write type gets ASYNC_WORKER_THREADS shards, and appendBatch opens at
most one transaction per shard that received samples, so a fixed bound would
vary with the CPU count of the machine running the test. Verified the guard
discriminates: restoring the per-sample shape measures 200 transactions against
a bound of 12.

Both handlers now record that the outer begin/commit is not atomicity across
batches - TimeSeriesShard.appendSamples commits its own shard writes on
getWrappedDatabaseInstance(), so a later failure cannot roll back batches
already written. That predates this change, but batching moves the granularity
from per-sample to per-measurement and the outer transaction reads as if it
covered the whole request.
@robfrank

Copy link
Copy Markdown
Collaborator Author

Addressed in 95f212b. Notes per item.

1. Prometheus tag hoisting — done, but the premise needs a correction.

The optimization is right and is now implemented. The stated reason is not: this is not extra work introduced by batching. The pre-change code also looped for (final Sample sample : ts.getSamples()), so findLabelValue already ran samples × tagColumns times per series. The batching commit moved that work around but did not add any.

What is true, and what makes it worth fixing, is that the value is invariant across samples and the column-major grid makes it trivial to exploit — plus findLabelValue is worse than a plain linear scan: it calls sanitizeColumnName on every label it visits, which is 3 String.replace allocations per label. So the cost was samples × tagColumns × labels × 3 allocations, in both the old and new shapes.

Filling the grid column by column instead of sample by sample lets each TAG column resolve once and broadcast down its column via Arrays.fill. Agreed that the line-protocol handler must stay per-sample — sample.getTags() genuinely varies per line — and it is unchanged.

2. Prometheus batching regression test — done, and it needed the shard count.

PostTimeSeriesWriteHandlerBatchAppendIT is renamed to TimeSeriesIngestBatchAppendIT and now owns the batching contract for both ingest handlers, with two remote_write cases (single series; two series of the same metric differing only by tag).

Writing it surfaced something worth flagging: my first attempt reused the line-protocol tests' hardcoded ≤ 2 bound and failed at 11. That is not a batching failure — an auto-created remote-write type gets ASYNC_WORKER_THREADS shards (not 1, unlike the line-protocol tests which pin SHARDS 1 in DDL), and appendBatch opens at most one transaction per shard that received samples. 11 shards, 11 transactions, exactly the contract. A hardcoded bound there would have been machine-dependent and flaky across CI runners with different CPU counts, so the assertion now reads the shard count back off the type and binds to shards + 1 (and asserts shards < SAMPLES / 4 so the bound stays meaningful).

Verified the guard actually discriminates rather than merely passing: with the per-sample shape temporarily restored, both new cases fail at 200 transactions against a bound of 12.

3. Empty remote-write series auto-creating a type — not changing this.

Correctly identified as pre-existing and not a regression; getOrCreateType ran before the (absent) sample loop too, so behaviour is byte-for-byte identical. I am leaving it: moving the empty check ahead of getOrCreateType is a semantic change to schema auto-creation on a path this PR only touches for batching, and a zero-sample series carrying labels is a plausible way for a client to pre-register a metric. That is a decision for someone with the Prometheus-compat context, not a drive-by in a perf PR. Happy to file it separately if wanted.

4. Partial-write window — documented in both handlers.

The claim checks out, and there is direct evidence for it in this PR: the writeTx statistic incremented 200 times for a 200-sample request while the handler's outer transaction was open, which only happens if the inner shard commits are independent. Both handlers now carry a note at database.begin() stating that it does not make the request atomic, that TimeSeriesShard.appendSamples commits its own shard writes via getWrappedDatabaseInstance(), and that a later failure cannot roll back batches already written.

Minor (schema lookups before begin()) — intentional and safe. They are metadata reads that need no transaction, and the resolved LocalTimeSeriesType is carried forward in the grouping structure, so the append pass does no second lookup that could observe a different schema.

Verification after these changes: server TS/Prometheus/Grafana/PromQL ITs 58/58; TimeSeriesThreeNodeWalGapReproIT still passes (17.9 s); ha-raft TS ITs 6/6.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review: batch time-series HTTP ingest

Reviewed the diff against both handlers, TimeSeriesEngine.appendBatch / TimeSeriesShard.appendSamples, and BaseGraphServerTest. This is a clean, well-motivated change - the write-up, inline comments, and test design are all strong. Summary below.

Strengths

  • Correct use of the existing primitive. appendBatch already did the round-robin sharding + one-transaction-per-shard grouping and was only reachable from tests; wiring the handlers to it is the right fix rather than inventing new batching. The n==1 fast path keeps single-sample requests cheap.
  • Behaviour genuinely preserved. LinkedHashMap/LinkedHashSet keep first-occurrence order, per-sample tag/field resolution is unchanged, and inserted += count keeps the written/dropped accounting consistent with the pre-existing partial-write 400 path. The round-robin shard assignment increments appendCounter once per sample in both the old and new paths, so shard distribution is identical.
  • Honest comments. The two NOTE: blocks documenting that the enclosing database.begin() does not make the request atomic (because TimeSeriesShard.appendSamples commits on getWrappedDatabaseInstance()) are exactly what future readers need. This non-atomicity is pre-existing, not introduced here.
  • Deterministic test. Using the writeTx stat (commits that produced WAL) as a timing-free proxy for shard append transactions is a nice choice, and reading the shard count back from the auto-created type instead of hardcoding a CPU-derived value makes remoteWriteSeriesIsOneAppendTransactionPerShard portable. The interleaved-measurement and two-series cases cover the grouping logic well.

Prometheus handler micro-optimisation

Resolving each TAG once via findLabelValue + Arrays.fill instead of per-sample is a good call, since findLabelValue re-sanitizes every label name it scans. The column-major fill matches appendBatch's documented [colIdx][sampleIdx] layout. Correct.

Minor / non-blocking notes

  1. PR-description/test-name mismatch. The description repeatedly refers to PostTimeSeriesWriteHandlerBatchAppendIT, but the committed file is TimeSeriesIngestBatchAppendIT. Cosmetic, but worth aligning.
  2. Duplication between the two handlers. Both now build a long[] timestamps + column-major Object[][] grid and call appendBatch. The sample types differ (LineProtocolParser.Sample vs PrometheusTypes.Sample), so extraction isn't free, but if a third ingest path ever appears this is the spot to factor out. Fine to leave as-is.
  3. Failure granularity shifted, as documented. If an appendBatch throws mid-loop, earlier measurements/series are already committed and the caller gets a 500 with a partial write - same shape as before, just at measurement/series rather than sample granularity. The comments call this out; no action needed. The deliberately-flagged RaftHAServer.getTransactionBroker()==null NPE-after-stop() follow-up is a real rough edge worth a tracking issue.
  4. Test coverage is standalone (1 node). getServerCount() defaults to 1, so the new IT validates the transaction-count contract but not the HA quorum-round-trip path that motivated the change. TimeSeriesThreeNodeWalGapReproIT covers the HA side, so combined coverage is good - just noting the new IT is a standalone proxy by design.

Nothing blocking from my side. Solid performance fix with a well-targeted regression test.

Automated review by Claude Opus 4.8. Reviewed statically; I did not run the build/tests myself.

@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.06349% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.28%. Comparing base (c885235) to head (95f212b).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
...erver/http/handler/PostTimeSeriesWriteHandler.java 93.33% 1 Missing and 2 partials ⚠️
...erver/http/handler/PostPrometheusWriteHandler.java 88.88% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5419      +/-   ##
============================================
+ Coverage     64.82%   65.28%   +0.45%     
- Complexity     1057     1058       +1     
============================================
  Files          1726     1726              
  Lines        143368   143652     +284     
  Branches      30697    30771      +74     
============================================
+ Hits          92934    93777     +843     
+ Misses        37683    37110     -573     
- Partials      12751    12765      +14     

☔ 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.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

1 participant