Skip to content

Check the feed hash before parsing - #1672

Merged
burma-shave merged 8 commits into
mainfrom
ab/1657-hash-first
Nov 8, 2026
Merged

burma-shave merged 8 commits into
mainfrom
ab/1657-hash-first

Conversation

@aaronbrethorst

@aaronbrethorst aaronbrethorst commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Part of #1657 (steps 2b and 2c). Stacked on #1669.

What it does

Two commits:

  1. Free the parsed feed before precomputing. storeConfiguredFeed loads, parses and stores the feed, so the parsed copy is reachable only inside it. ReloadStatic calls debug.FreeOSMemory and then precomputes stop directions. This runs only when the data changed, at most once per daily reload.
  2. Check the feed hash before parsing. fetchFeedFile downloads a remote feed next to the database while hashing it (a local feed is hashed in place); downloads left behind by a crash are removed first. storeConfiguredFeed asks the new Client.HasImported and parses only when the database does not already hold the feed. StoreGtfsData keeps its own check; both use one isSameFeed rule. The download clones http.DefaultTransport, so proxy environment variables apply.

When the feed is unchanged, both paths call Client.RefreshFeedExpiry, so a database written by an older build still picks up the corrected expiry from #1686 without a re-import.

Root cause

Every startup and daily reload read the whole feed into memory and parsed it before StoreGtfsData compared hashes, so a restart with an unchanged feed reached nearly the same memory peak as a first import. Separately, ReloadStatic kept the parsed feed alive in a local variable while directions were precomputed.

Testing

  • Test failing on main before this change: n/a (memory change). The expiry-refresh interaction is pinned by TestReloadStaticUnchangedFeedRefreshesExpiry, which fails without the RefreshFeedExpiry call in the short-circuit:
# maglev.onebusaway.org/internal/gtfs.test
--- FAIL: TestReloadStaticUnchangedFeedRefreshesExpiry (0.13s)
    static_test.go:117: 
        	Error Trace:	/Users/aaron/repos/onebusaway/red-1672/internal/gtfs/static_test.go:117
        	Error:      	Not equal: 
        	            	expected: time.Date(2026, time.January, 1, 7, 59, 59, 0, time.UTC)
        	            	actual  : time.Date(2025, time.December, 31, 7, 59, 59, 0, time.UTC)
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -1,2 +1,2 @@
        	            	-(time.Time) 2026-01-01 07:59:59 +0000 UTC
        	            	+(time.Time) 2025-12-31 07:59:59 +0000 UTC
        	            	 
        	Test:       	TestReloadStaticUnchangedFeedRefreshesExpiry
FAIL
FAIL	maglev.onebusaway.org/internal/gtfs	0.344s
FAIL
  • Manual check: NJ Transit bus import, TestImportMemory, at the previous PR in the stack and at this branch:
previous PR (#1669) this PR
heap at start of precompute (precomputing_stop_directions_started) 809.01 MiB 108.03 MiB
process peak RSS 992.86 MiB 954.00 MiB
import time 23.192s 21.318s

Full per-stage output is in the TestImportMemory log; the table shows the stages this PR moves.

Tests:

  • TestFetchFeedFileLocal: a local feed is hashed in place with the expected hash.
  • TestFetchFeedFileDownload: the auth header is sent, the file lands next to the database and cleanup removes it; a 403 leaves no file behind.
  • TestFetchFeedFileTruncatedDownloadLeavesNoFile: a server that promises more bytes than it sends; the fetch fails and the database directory is empty.
  • TestFetchFeedFileServerUnreachable: a closed server gives the download error.
  • TestFetchFeedFileRemovesStaleDownloads: a stale download next to the database is removed before a new one starts.
  • TestDownloadDir: "", :memory: and a file path.
  • TestFeedHashIncludesGTFSTidy, TestParseHashMarksTidyOnlyWhenItRan, TestParseHashMarksTidyWhenItRan: the +gtfstidy marker is added only when tidying ran (the success case uses a fake gtfstidy on PATH).
  • TestHasImported: empty database, same feed, other source, other hash.
  • TestReloadStaticSkipsParseForImportedFeed: a reload of the same feed does not parse again.
  • TestReloadStaticPrecomputesOnlyWhenChanged: directions are precomputed after a changed import and not after an unchanged reload.
  • TestReloadStaticUnchangedFeedRefreshesExpiry, TestReloadStaticUnchangedFeedExpiryFailure: the skipped parse still refreshes feed_expires_at; a refresh failure fails the reload without parsing.
  • TestReloadStaticHasImportedError: a failing metadata query fails the reload with its error.

Checklist

  • This PR addresses one issue.
  • It is under ~200 changed lines, or the description says why it can't be split.
  • This PR is not a bug fix, or the new or changed test fails on main and passes on this branch.
  • Each commit is one logical change with a clear message.
  • I did not hand-edit sqlc-generated files under gtfsdb/ (db.go, models.go, query.sql.go) or testdata/openapi.yml (synced from upstream).
  • This PR changes no handler, or handlers pass r.Context() to DB/service calls, not context.Background().
  • The response comparison above is filled in, or this PR doesn't change a Java-served response.
  • This PR adds no endpoint, or each new endpoint matches testdata/openapi.yml, has an issue open on OneBusAway/sdk-config, or is outside the SDK and the description says why.
  • I read every line of this diff and can explain it if asked.

Notes for reviewers

Size. 660 added, 134 removed; 367 of the additions are tests. The two commits are separable in principle, but the second moves the code the first introduced, so splitting them would mean reviewing the same lines twice.

Compatibility of the hash.

  • Without gtfstidy the hash is still the SHA-256 of the feed bytes, so existing databases match and do not re-import.
  • With gtfstidy the hash is now taken before tidying and marked +gtfstidy, only when tidying succeeded. Turning tidy on or off still forces a re-import. A database imported with tidy on re-imports once after this change. A failed tidy is retried on the next reload instead of being recorded as done; while tidy keeps failing, every reload parses the feed again, as before this PR.

Expiry refresh failure. A refresh that fails (for example an agency row with an invalid timezone) fails the reload, in both paths. That matches what #1686 does inside StoreGtfsData.

Follow-up. With gtfstidy on, tidyGTFSData still copies the feed into the default temp directory; it should work in the download directory instead.

@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 29 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 8b7071e7-14aa-407f-9d2b-d750549328ec
Commits

Reviewing files that changed from the base of the PR and between 4d64a18 and bebd526.

Files selected for processing: 9

  • gtfsdb/conditional_import_test.go
  • gtfsdb/helpers.go
  • internal/gtfs/feed_file.go
  • internal/gtfs/feed_file_test.go
  • internal/gtfs/realtime.go
  • internal/gtfs/static.go
  • internal/gtfs/static_test.go
  • internal/gtfs/tidy.go
  • internal/gtfs/tidy_test.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@github-actions

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 1.1 ms
Error rate 0.00%
Total requests 334
Req/sec 11.0

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

@github-actions

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 2.0 ms
Error rate 0.00%
Total requests 336
Req/sec 11.1

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

@github-actions

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 1.4 ms
Error rate 0.00%
Total requests 348
Req/sec 11.4

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

@aaronbrethorst aaronbrethorst added the skip-template-check Skip the PR template check (small fixes, or PRs opened before the check existed) label Oct 21, 2026

@burma-shave burma-shave 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.

One correctness finding, inline.


Generated by Claude Code

Comment thread internal/gtfs/feed_file.go Resolved
@github-actions

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 1.9 ms
Error rate 0.00%
Total requests 340
Req/sec 11.2

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

@github-actions

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 1.7 ms
Error rate 0.00%
Total requests 332
Req/sec 10.8

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

Comment thread internal/gtfs/feed_file.go Resolved
Comment thread internal/gtfs/feed_file.go Resolved
Comment thread internal/gtfs/feed_file.go Resolved
Comment thread internal/gtfs/feed_file.go Resolved
Comment thread internal/gtfs/feed_file.go Resolved
Comment thread internal/gtfs/feed_file.go Resolved
Comment thread internal/gtfs/static.go Resolved
Comment thread internal/gtfs/static.go Resolved
@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 2.1 ms
Error rate 0.00%
Total requests 344
Req/sec 11.4

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 2.0 ms
Error rate 0.00%
Total requests 331
Req/sec 10.8

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 1.6 ms
Error rate 0.00%
Total requests 337
Req/sec 11.1

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 2.2 ms
Error rate 0.00%
Total requests 326
Req/sec 10.8

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 2.1 ms
Error rate 0.00%
Total requests 334
Req/sec 11.0

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 1.9 ms
Error rate 0.00%
Total requests 334
Req/sec 11.0

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 1.8 ms
Error rate 0.00%
Total requests 326
Req/sec 10.7

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

@github-actions

github-actions Bot commented Nov 8, 2026

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 2.0 ms
Error rate 0.00%
Total requests 334
Req/sec 10.9

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

@github-actions

github-actions Bot commented Nov 8, 2026

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 1.6 ms
Error rate 0.00%
Total requests 331
Req/sec 10.9

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

Base automatically changed from ab/1657-stream-stop-times to main November 8, 2026 02:53
ReloadStatic held the parsed feed in a local variable that it read
again after the import, so the whole feed stayed reachable while stop
directions were precomputed. For NJ Transit bus that kept several
hundred MB alive through a stage that needs about a hundred.

storeConfiguredFeed now loads, parses and stores the feed, and the
parsed copy is reachable only from there. ReloadStatic precomputes
directions after it returns, first calling debug.FreeOSMemory so the
feed's memory is collected and returned to the OS instead of waiting
for the GC to get around to it. importStaticIntoDB, which did the
store and the precompute together, is replaced by
precomputeStopDirections.

On NJ Transit, the precompute starts at 113 MiB of heap instead of
about 630 MiB, and the process's peak RSS falls from 1.0 GiB to about
950 MiB. The peak is now the parse itself (about 555 MiB of heap),
which only a streaming parser (#1657, step 3) can lower.
Every startup and every daily reload read the whole static feed into
memory and parsed it, and only then did StoreGtfsData compare its hash
with import_metadata. So a restart with an unchanged feed and an
existing database reached nearly the same memory peak as a first
import.

fetchFeedFile now puts the feed in a local file while hashing it: a
local feed is read where it is, and a remote one is downloaded next to
the database rather than to a temporary directory that may be held in
memory. Downloads left behind by a crash are removed first.
storeConfiguredFeed asks the new Client.HasImported whether the
database already holds that hash from that source, and parses the
feed only when it doesn't. HasImported and StoreGtfsData share one
isSameFeed rule.

The hash is still the SHA-256 of the feed bytes, so existing databases
match and don't re-import. parse hashes the bytes it actually reads,
so the stored hash always describes the stored data. With gtfstidy on,
the hash is marked "+gtfstidy" only when tidying succeeded: turning
tidy on or off causes a re-import, and a failed tidy is retried on the
next reload instead of being recorded as done. A database imported
with tidy on re-imports once.

The static download now clones http.DefaultTransport, like the
realtime client, so it honors proxy settings from the environment.

For NJ Transit bus with an existing database, a restart is healthy in
1 s with a peak RSS of 194 MB. On main it takes 4 s and 825 MB.
With gtfstidy on, a feed was stored under its plain hash when tidying
failed, but every reload looked for the hash marked "+gtfstidy". The
check never matched, so a feed whose tidy always fails was read,
tidied again and parsed on every restart and reload, and only then
skipped by StoreGtfsData. Hash-first never helped such a feed.

The import hash now depends only on the feed's content and whether
tidy is on. A failed tidy is stored under the marked hash, so the next
reload skips the feed, and tidy is retried when the feed changes.
The hash-first check in storeConfiguredFeed and the check inside
StoreGtfsData each compared import_metadata and refreshed the feed
expiry on their own, and logged the skip differently: one with the
full hash, the other with its first 8 characters. A later change to
one path would make an unchanged feed behave differently depending on
which check caught it.

Client.SkipIfImported replaces HasImported and, like StoreGtfsData,
goes through skipUnchangedFeed, which compares the feed, refreshes the
expiry and logs gtfs_data_unchanged_skipping_import with the short
hash. RefreshFeedExpiry is no longer needed outside gtfsdb, so it is
unexported.
Before each download, every gtfs-static-*.zip in the database's
directory, or in the temporary directory for an in-memory database,
was removed. Reloads are serialized only within one Manager, so a
second process whose database shares that directory, or a second
in-memory process, could remove a download between its fetch and its
parse and fail the reload. Any operator file that matched the pattern
was removed too.

A download's name now carries its owner: the database file's name, or
the process ID for an in-memory database. A process removes only its
own leftovers, matched by the exact name os.CreateTemp gives them.

The database path is also read as SQLite reads it. A "file:" URI,
such as file:/data/gtfs.db?cache=shared, was taken as a directory
named "file:", so every remote reload failed, and
file::memory:?cache=shared sent downloads to the working directory.
downloadFeedFile hashes the feed while it writes it to disk, and parse
then hashed the same bytes again, about a second of extra work on a
200 MB feed. Only a local feed can change between the fetch and the
parse; a download is a file private to this import.

feedFile now records whether it is private, and parse hashes again
only a local feed.
With gtfstidy on, parse read the whole feed from disk into memory only
so tidyGTFSData could write it back out to a temporary file for
gtfstidy. That added feed-sized copies at the memory peak this change
set out to lower.

tidyGTFSFile now gives gtfstidy the feed's own path, made absolute
because gtfstidy runs in its temporary directory. The feed is read into
memory untidied only when tidying fails.
Each reload built a new client with a new transport and never closed
it, so the keep-alive connection and its goroutines stayed idle for
90 seconds on a transport nothing would use again. A package-level
client, like the realtime one, is shared by every reload instead.

The download timeouts are now named constants, and the static and
realtime clients clone http.DefaultTransport through one
cloneDefaultTransport helper.
@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Nov 8, 2026

Copy link
Copy Markdown

Performance Smoke Test Results

Status: PASSED

Metric Value
p(95) latency 2.8 ms
Error rate 0.00%
Total requests 339
Req/sec 11.1

Smoke test config: 5 VUs x 30s. Thresholds: p(95) < 300ms, error rate < 1%.

Full results uploaded as workflow artifact: k6-smoke-summary.

@burma-shave
burma-shave merged commit 2a171cd into main Nov 8, 2026
13 checks passed
@aaronbrethorst
aaronbrethorst deleted the ab/1657-hash-first branch November 8, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-template-check Skip the PR template check (small fixes, or PRs opened before the check existed)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants