Skip to content

GTFS-RT: constrained repro + Fix 1 (memoize BlockStopTimeList.get)#473

Open
aaronbrethorst wants to merge 5 commits into
mainfrom
gtfsrt-constrained-repro
Open

GTFS-RT: constrained repro + Fix 1 (memoize BlockStopTimeList.get)#473
aaronbrethorst wants to merge 5 commits into
mainfrom
gtfsrt-constrained-repro

Conversation

@aaronbrethorst

@aaronbrethorst aaronbrethorst commented Jul 11, 2026

Copy link
Copy Markdown
Member

Follow-up to #472. Two things: a production-faithful reproduction of the GTFS-RT poll path, and the implementation of that report's top-ranked fix.

Summary

  • Faithful constrained reproduction. Rebuilt the poll reproduction to mirror the real production container (multi-agency rule-set-19 build-56 bundle — 5 agencies; live KCM feed producing a real per-cycle ERROR flood; the production [%F:%L] log config; docker --cpus=1 --memory=2g; TZ=America/Los_Angeles). Result: the tempting "expensive [%F:%L] location logging × flood" hypothesis is refuted (A/B/C over prod/cheap/quiet log configs — deltas are smaller than run-to-run GC noise), and even fully faithful + constrained, one 30 s poll is ~0.7 % of a core — i.e. not the 85 % peg. Evidence points to a continuous driver (API read traffic / cadence), not the single poll.
  • Fix 1 — memoize BlockConfigurationEntryImpl$BlockStopTimeList.get(). It rebuilt a BlockStopTimeEntryImpl on every call — the largest allocation source on the poll and arrivals-read paths. Now a lazy transient cache (thread-safe without locking: the entry has only final fields). Measured on the constrained harness (3 reps): allocation/refresh 542 MB → 159 MB (−71 %), p50 refresh 188 ms → 143 ms (−24 %); mean/p95 are GC-timing noise on desktop HW. Also lightens the arrivals read path (shared getBlockLocation.get()).
  • Adds a committed, isolated perf-harness module (onebusaway-gtfsrt-perf: benchmark with a per-thread allocation counter, agency-pinned harness, run-constrained.sh, log4j2 A/B/C configs) and the findings write-up (docs/perf/2026-07-10-gtfsrt-matching-findings.md, Addendum §A–E).

Test plan

  • BlockConfigurationEntryImplTest — values correct, elements memoized (assertSame), and populatedCacheSerializesCleanly (guards the load-bearing transient; verified it fails with NotSerializableException when transient is removed, passes with it)
  • ScheduledBlockLocationServiceImplTest (14) and golden GtfsRealtimeTripLibraryCharacterizationTest pass unchanged
  • impl.transit_graph + impl.blocks + impl.realtime packages green
  • End-to-end constrained benchmark run through GtfsRealtimeSource.refresh(): matched 373/398, alloc 159 MB/refresh post-fix
  • license:check passes on both modules

Review notes (non-blocking)

  • The 85 % peg is not reproduced by any poll-path experiment. The report recommends confirming the box's real http_request_count and deployed refreshInterval, then a concurrent read-load reproduction against getBlockLocation (now cheaper post-Fix 1) — not done here.
  • Fix 1 trades per-call allocation for retained heap (one wrapper per touched block-stop-time, bounded to the working set; documented at the field). The −71 % allocation cut should convert to lower GC CPU on a memory-pressured/slower box — worth re-measuring on the target.
  • The perf-harness module has no unit tests (measurement scaffolding, judged at a light bar); its only branching logic is parseCsv.

Summary by CodeRabbit

  • Performance
    • Reduced per-refresh allocations by ~71% and improved median refresh latency by ~24% while preserving behavior.
    • Added allocation reporting in the benchmark and validated results in production-faithful, constrained conditions.
  • New Features
    • Added benchmark support for limiting polling to specific agency IDs.
    • Introduced production-like, low-overhead (“cheap”), and muted (“quiet”) logging modes.
  • Documentation
    • Added a full reproduction guide and updated benchmark findings addendum.
  • Tests
    • Added tests to verify stop-time memoization stability and safe serialization.

Follow-up to the PR #472 perf investigation. The baseline there used a
single-agency KCM bundle on an unconstrained desktop and could not reproduce
the production 85% CPU peg. This rebuilds the reproduction to mirror the actual
production container (agency-dockerfiles/test-organization-1):

- multi-agency rule-set-19 build-56 bundle (5 agencies) via BuildKcmBundle over
  an <agencyId>/<feed>.zip input tree
- feed pinned to agencyIds=["1"] (PerfBundleHarness now calls setAgencyId;
  GtfsRealtimeSource.start() otherwise defaults to all agencies)
- live KCM feed vs stale build-56 -> a real per-cycle ERROR flood (~52 lines/refresh)
- the production [%F:%L] log config, plus cheap/quiet variants for an A/B/C
- docker --cpus=1 --memory=2g, TZ=America/Los_Angeles (required; UTC NPEs in
  BlockFinder service-date math)

Results (docs/perf addendum):
- The "expensive [%F:%L] logging x flood" hypothesis is REFUTED: config-to-config
  deltas (~10-25ms) are smaller than run-to-run GC-timing spread (75-155ms).
- Even fully faithful and constrained, one 30s poll is ~195-210ms median
  (~0.7% of a core at 30s cadence) and does not peg. The per-refresh allocation
  waste is reconfirmed; the 85% peg points to a continuous driver (read traffic
  / cadence), not the single poll.

Adds: PerfBundleHarness agency pinning, perf/logconfigs/log4j2-{prod,cheap,quiet}.xml,
perf/run-constrained.sh, perf/README.md, and the findings addendum.
BlockConfigurationEntryImpl$BlockStopTimeList.get(index) rebuilt a new
BlockStopTimeEntryImpl on every call — the single largest allocation source on
the GTFS-RT poll and arrivals read paths (docs/perf findings, Fix 1: ~59-69% of
all allocation). The wrapped inputs are fixed for the bundle's lifetime, so the
wrappers are effectively immutable.

Populate a lazy, transient BlockStopTimeEntry[] cache instead (only touched
indices retained; transient so bundle serialization is unchanged). Thread-safe
without locking: BlockStopTimeEntryImpl has only final fields, so a racing
reader sees either null (rebuild an equivalent wrapper) or a safely-published
instance.

Measured before/after on the constrained multi-agency harness (quiet config,
--cpus=1 --memory=2g, warmup 8/measure 40, 3 reps each):
- allocation/refresh: 542 MB -> 159 MB  (-71%)
- p50 refresh:        188 ms -> 143 ms  (-24%)
- mean/p95: GC-pause-timing noise on desktop hardware (both before and after
  hit occasional ~1.5s pauses); the 71% alloc cut should convert to lower GC
  CPU on a more memory-pressured/slower box.

Tests: adds BlockConfigurationEntryImplTest (values correct + elements memoized;
fails pre-fix on identity, passes post-fix). ScheduledBlockLocationServiceImplTest
(14) and the golden GtfsRealtimeTripLibraryCharacterizationTest pass unchanged.
Adds a ThreadMXBean allocation counter to GtfsRtRefreshBenchmark and an EXTRA_CP
hook to run-constrained.sh for measuring a rebuilt module without mvn install.
Per /simplify altitude review: PerfBundleHarness.open() read
System.getProperty("perf.agencyIds") directly, unlike every other harness
input (bundle root, feed URLs) which GtfsRtRefreshBenchmark.main() parses and
passes via constructor. Move the parse to main(), pass a List<String> through a
new 5-arg constructor (4-arg overload delegates with no pinning, keeping
StopCountHistogram untouched), and use the bulk setAgencyIds(List) setter
instead of a per-token setAgencyId loop. Behavior unchanged: pinned agencyIds=[1],
matched=373/398.
…tations

From /pr-review-toolkit:review-pr:
- pr-test-analyzer (Important): the cache field's 'transient' is load-bearing
  because BlockStopTimeEntryImpl is not Serializable, and configs are serialized
  after their caches populate during block-index computation. Add
  populatedCacheSerializesCleanly() -- verified it fails with
  NotSerializableException when 'transient' is removed, passes with it. Refactor
  the fixture into a shared helper.
- comment-analyzer: replace brittle cross-repo 'Dockerfile line 4/8' citations
  (external agency-dockerfiles repo; line numbers rot silently) with the setting
  name in log4j2-prod.xml, run-constrained.sh, and the findings addendum.

code-reviewer and silent-failure-hunter: clean, no findings.
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ada7dd1c-ffdb-420e-876b-a70055244e63

📥 Commits

Reviewing files that changed from the base of the PR and between bfdc64c and d85a17c.

📒 Files selected for processing (1)
  • onebusaway-gtfsrt-perf/src/main/java/org/onebusaway/perf/gtfsrt/GtfsRtRefreshBenchmark.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • onebusaway-gtfsrt-perf/src/main/java/org/onebusaway/perf/gtfsrt/GtfsRtRefreshBenchmark.java

📝 Walkthrough

Walkthrough

The PR adds constrained GTFS-RT benchmark tooling with configurable logging, agency filtering, and allocation reporting; memoizes block stop-time entries; adds regression tests; and documents production-faithful reproduction results and validation metrics.

Changes

GTFS-RT performance investigation

Layer / File(s) Summary
Memoize block stop-time entries
onebusaway-transit-data-federation/src/main/java/.../BlockConfigurationEntryImpl.java, onebusaway-transit-data-federation/src/test/.../BlockConfigurationEntryImplTest.java
BlockStopTimeList.get(int) now lazily caches transient entries, with tests for values, instance reuse, and serialization.
Constrained benchmark and measurement controls
onebusaway-gtfsrt-perf/src/main/java/.../GtfsRtRefreshBenchmark.java, onebusaway-gtfsrt-perf/src/main/java/.../PerfBundleHarness.java, onebusaway-gtfsrt-perf/perf/*
The benchmark adds agency filtering and allocation statistics; scripts, logging profiles, and README instructions define constrained production, cheap, and quiet runs.
Document benchmark findings
docs/perf/2026-07-10-gtfsrt-matching-findings.md
The report records reproduction results, logging conclusions, memoization measurements, interpretation, and experiment provenance.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the PR’s main changes: a constrained GTFS-RT reproduction and the BlockStopTimeList.get memoization fix.
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.
✨ 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 gtfsrt-constrained-repro

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 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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
`@onebusaway-gtfsrt-perf/src/main/java/org/onebusaway/perf/gtfsrt/GtfsRtRefreshBenchmark.java`:
- Around line 90-102: Update threadAllocatedBytes() to enable thread
allocated-memory monitoring before calling getThreadAllocatedBytes(...): when
the bean is a com.sun.management.ThreadMXBean and
isThreadAllocatedMemorySupported() is true, check
isThreadAllocatedMemoryEnabled() and call setThreadAllocatedMemoryEnabled(true)
if needed, then perform the measurement while preserving the existing fallback
behavior.
🪄 Autofix (Beta)

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: d0e819fe-3fd7-44a6-9f38-9215e8eb533d

📥 Commits

Reviewing files that changed from the base of the PR and between 07fd254 and bfdc64c.

📒 Files selected for processing (10)
  • docs/perf/2026-07-10-gtfsrt-matching-findings.md
  • onebusaway-gtfsrt-perf/perf/README.md
  • onebusaway-gtfsrt-perf/perf/logconfigs/log4j2-cheap.xml
  • onebusaway-gtfsrt-perf/perf/logconfigs/log4j2-prod.xml
  • onebusaway-gtfsrt-perf/perf/logconfigs/log4j2-quiet.xml
  • onebusaway-gtfsrt-perf/perf/run-constrained.sh
  • onebusaway-gtfsrt-perf/src/main/java/org/onebusaway/perf/gtfsrt/GtfsRtRefreshBenchmark.java
  • onebusaway-gtfsrt-perf/src/main/java/org/onebusaway/perf/gtfsrt/PerfBundleHarness.java
  • onebusaway-transit-data-federation/src/main/java/org/onebusaway/transit_data_federation/impl/transit_graph/BlockConfigurationEntryImpl.java
  • onebusaway-transit-data-federation/src/test/java/org/onebusaway/transit_data_federation/impl/transit_graph/BlockConfigurationEntryImplTest.java

isThreadAllocatedMemorySupported() only checks JVM support; the measurement is
disabled by default per the JDK spec, so getThreadAllocatedBytes() can return -1
on a capable HotSpot JVM that has it off (it happened to be on for the runs in
this PR). Enable it (idempotently) before reading so the ALLOC report is robust
across JVMs. Verified ALLOC still reports 165.6MB/refresh.
@aaronbrethorst

Copy link
Copy Markdown
Member Author

Addressed the CodeRabbit review:

  • Fixed (GtfsRtRefreshBenchmark.threadAllocatedBytes()): now enables thread-allocated-memory monitoring before reading. isThreadAllocatedMemorySupported() only checks JVM support; the measurement is disabled-by-default per the JDK spec, so getThreadAllocatedBytes() could return -1 on a capable JVM. Enabling it idempotently makes the ALLOC report robust across JVMs. Verified it still reports ~165 MB/refresh. (d85a17c)
  • Skipped — Docstring Coverage (33% < 80%): a heuristic over all methods including trivial test/benchmark helpers. The load-bearing methods (the memoization cache field, the serialization guard test, threadAllocatedBytes) are documented; padding trivial helpers to hit a threshold would add noise. The perf module is measurement scaffolding, judged at a light bar.

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