fix: bound MemoryStore retention so DB-less mode stops growing until OOM - #64
Open
GautamSharma99 wants to merge 3 commits into
Open
fix: bound MemoryStore retention so DB-less mode stops growing until OOM#64GautamSharma99 wants to merge 3 commits into
GautamSharma99 wants to merge 3 commits into
Conversation
GetChildJobs ranged over the job map and returned whatever order Go's randomised map iteration produced. PostgresStore orders by created_at, so the two implementations behind one JobStore interface disagreed. GetBatchJob turns that order into each result's index, so in the DB-less mode the README leads with, polling the same batch twice returned the results — and the response's own urls list — in different orders, with index identifying nothing. MemoryStore now records an insertion sequence and sorts on it. The sequence rather than CreatedAt, because time.Now().UTC() strips the monotonic clock reading: the stored timestamp is wall-clock only, so it is subject to NTP adjustment and, on platforms with coarse resolution, ties outright for batch children created in a tight loop. The sequence is assigned inside the same critical section as the insert, so it is exactly creation order. Storing it needs a wrapper around the record, which is why the other methods change shape; none of their behaviour does. internal/store had no tests. Adds the regression test — eight children created back to back with no delay, order asserted over 50 calls — plus coverage for copy-on-read and concurrent access. The regression test fails on the previous implementation at the first attempt.
…contract suite
The two JobStore implementations had no shared tests, which is how the child
ordering divergence survived. This adds a contract suite that runs the same
assertions against both: MemoryStore always, PostgresStore when
TEST_DATABASE_URL points at a database with the scripts/init-db.sql schema.
The suite immediately found a second divergence. PostgresStore.CreateJob
inserts the literal 'pending' for status; MemoryStore copied the caller's
JobRecord, and no caller sets Status, so jobs were stored with "".
That is not cosmetic. UpdateParentBatchStatus counts children by status, and ""
matches neither the pending nor the processing branch — so `pending == total` is
false, `pending > 0 || processing > 0` is false, and the parent falls through to
the completed branch with CompletedAt stamped. GetBatchJob derives the response
status the same way. In DB-less mode a batch therefore reported completed the
moment it was submitted, before any child had run.
MemoryStore now sets pending on create, matching Postgres, which hardcodes it
and ignores the caller's value for the same reason.
Verified against both backends:
TEST_DATABASE_URL=... go test -race ./internal/store/
ok .../internal/store 1.650s
Coverage: internal/store 0% -> 50.9%.
MemoryStore never removed anything — no eviction, no TTL, no cap — so the map
grew for the life of the process. What accumulates is not small: each completed
job retains the serialised result, which carries the raw HTML (up to the 10 MB
read limit in the HTTP handler), the cleaned HTML and the markdown, so roughly
two to three copies of every page body. A server scraping 500 KB pages at one
job per second accumulates on the order of a gigabyte an hour, with no
backpressure and no log line before the OOM kill.
This is the backend selected whenever DATABASE_URL is unset, which is the
zero-config mode the README leads with.
Adds a retention cap: MEMORY_STORE_MAX_JOBS, default 500, evicting oldest-first
once exceeded. Zero or less restores the previous unbounded behaviour for anyone
relying on it. Eviction shrinks to 90% of the cap rather than exactly to it, so
it runs once per ~10% of capacity instead of on every insert once full.
Two properties the policy has to preserve, both tested:
- A batch parent and its children are evicted as a unit. Dropping a parent
while children remain would strand them; dropping children while the parent
remains would silently shrink the batch response.
- In-flight jobs are never evicted. A worker is still writing to them and a
sync request may be polling, so eviction would surface as "not found". The
cap is therefore a target, not a hard ceiling — the overshoot is bounded by
WORKER_POOL_SIZE + JOB_BUFFER_SIZE.
Families are ordered by their most recently inserted member so the newest work
is evicted last, which keeps a just-completed job from being dropped before its
sync caller reads it. Verified end to end: 60 consecutive sync scrapes against a
server running MEMORY_STORE_MAX_JOBS=5 all returned 200 across 28 eviction
cycles.
The three eviction tests fail on the previous implementation, the payload one
reporting 13107200 bytes retained against a 1310720 ceiling.
Coverage: internal/store 50.9% -> 59.7%.
Fixes Anakin-Inc#57
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #57.
The problem
MemoryStorenever removed anything — no eviction, no TTL, no cap, and noDeleteon the interface. What accumulates is not small: each completed job retains the serialised result, carrying the raw HTML (up to the 10 MB read limit in the HTTP handler), the cleaned HTML and the markdown, so roughly two to three copies of every page body. A server scraping 500 KB pages at one job/second accumulates on the order of a gigabyte an hour, with no backpressure and no log line before the OOM kill.This is the backend selected whenever
DATABASE_URLis unset — the zero-config mode the README leads with.The fix
A retention cap:
MEMORY_STORE_MAX_JOBS, default 500, evicting oldest-first once exceeded.0or less restores the previous unbounded behaviour for anyone relying on it.Eviction shrinks to 90% of the cap rather than exactly to it, so it runs once per ~10% of capacity instead of on every insert once the store is full.
I implemented the count cap (option 1 in the issue) and not the TTL sweep (option 2), even though I'd said I leaned toward both. The cap alone gives the hard bound that fixes the bug; a TTL only changes when memory is released while already under the cap, and it needs a background goroutine plus shutdown plumbing through
main.go. Easy to add later against this structure if you want idle servers to release sooner. Option 3 (dropping HTML from the retained payload) changes whatGET /v1/url-scraper/:idreturns, so it felt like a product decision rather than a bug fix.Two properties the policy has to preserve
Both are tested, and both are the "care" the issue asked for:
not found. The cap is therefore a target, not a hard ceiling — the overshoot is bounded byWORKER_POOL_SIZE + JOB_BUFFER_SIZE, and that tradeoff is documented at the function.Families are ordered by their most recently inserted member, so the newest work is evicted last. That's what keeps a just-completed job from being dropped before its sync caller reads it.
Verification
The three eviction tests fail on the previous implementation:
The payload test is the one that matters — it asserts the bug is fixed in bytes, not just in entry count.
Because the real risk here is evicting a job out from under a live sync request, I also checked it end to end rather than only in unit tests. Server in memory mode with the cap set absurdly low, against a local origin:
60/60 succeeded across 28 eviction cycles at a cap of 5, so the ordering holds under far more churn than any real deployment.
Coverage:
internal/store50.9% → 59.7%,internal/configstays at 96.4% with the new var covered.Full CI parity locally —
gofmt -lclean,go build ./...,go vet ./...,go test -race ./...all pass.Notes for the reviewer
NewMemoryStore()keeps its signature and now defaults toDefaultMaxJobs;NewMemoryStoreWithLimit(n)is the explicit form. Adding a parameter to the existing constructor would have broken the call site in fix: preserve target HTTP status so blocked proxies take the severe penalty #59.internal/confignow importsinternal/storeforDefaultMaxJobs, so the default lives in one place. No cycle —storeimports nothing fromconfig..env.exampleand the README config table.## UnreleasedCHANGELOG heading.