Skip to content

fix: bound MemoryStore retention so DB-less mode stops growing until OOM - #64

Open
GautamSharma99 wants to merge 3 commits into
Anakin-Inc:masterfrom
GautamSharma99:fix/memorystore-eviction
Open

fix: bound MemoryStore retention so DB-less mode stops growing until OOM#64
GautamSharma99 wants to merge 3 commits into
Anakin-Inc:masterfrom
GautamSharma99:fix/memorystore-eviction

Conversation

@GautamSharma99

Copy link
Copy Markdown

Fixes #57.

Stacked on #63. This branch is based on fix/memorystore-child-order, because both change internal/store/memory.go and building on master would have meant a hand-merge. The first two commits below belong to #63; the change to review here is 363de45. Once #63 merges this diff collapses to that single commit automatically. Happy to rebase onto master instead if you'd rather take them in the other order.

The problem

MemoryStore never removed anything — no eviction, no TTL, no cap, and no Delete on 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_URL is 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. 0 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 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 what GET /v1/url-scraper/:id returns, 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:

  • Batch families are evicted as a unit. Dropping a parent while its 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, 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:

--- FAIL: TestMemoryStore_EvictsOldestOnceOverLimit
    retained 500 jobs, want at most the 20 limit
    oldest job survived; eviction is not oldest-first
--- FAIL: TestMemoryStore_EvictionReleasesStoredResults
    retained 13107200 bytes of results, want at most 1310720
--- FAIL: TestMemoryStore_NeverEvictsInFlightJobs
    retained 70 jobs after the in-flight ones completed, want at most 10

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:

$ MEMORY_STORE_MAX_JOBS=5 TELEMETRY=off ./server
$ # 60 consecutive POST /v1/scrape
200s: 60   non-200: 0
eviction log lines: 28
{"msg":"memory store evicted oldest jobs","evicted":2,"retained":4,"max":5}

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/store 50.9% → 59.7%, internal/config stays at 96.4% with the new var covered.

Full CI parity locally — gofmt -l clean, go build ./..., go vet ./..., go test -race ./... all pass.

Notes for the reviewer

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
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.

MemoryStore never evicts jobs — the default zero-config mode grows unbounded until OOM

1 participant