fix(analysis): make analysis generation GC progress across deadline-limited runs by peterkc · Pull Request #858 · zzet/gortex · GitHub
Skip to content

fix(analysis): make analysis generation GC progress across deadline-limited runs - #858

Open
peterkc wants to merge 4 commits into
zzet:mainfrom
peterkc:fix/analysis-gc-progress
Open

peterkc wants to merge 4 commits into
zzet:mainfrom
peterkc:fix/analysis-gc-progress

Conversation

@peterkc

@peterkc peterkc commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Analysis-generation GC could not make lasting progress on a large backlog, so the store kept growing. On one v0.64.5 store, all 28 analysis generations were stale, analysis_nodes held about 11 million rows, the analysis_* tables took 5.41 GB, and the daemon log had 53 analysis generation prune failed ... context deadline exceeded warnings. Two causes:

  1. Deleting an analysis_nodes row checks the analysis_process_steps.node_rowid foreign key. The only index containing node_rowid leads with generation_id, so each delete scanned every generation's process steps. A 1000-row chunk could then take longer than the 30 s prune deadline, roll back and never commit.
  2. Each pass deleted eligible generations newest-first and ended at its 30 s deadline. The next pass, if any, started on a different generation, so partial work never added up.

Refs #843, point 1 of this comment.

Before
  prune (30 s) --> newest eligible generation --> nodes chunk scans all process steps per row
               --> deadline --> chunk rolls back --> no retry

After
  prune (30 s) --> oldest eligible generation --> nodes chunk uses the node_rowid index
               --> deadline? --> retry after 30 s, doubling to 5 min, same generation first
               --> other error or drain --> stop

Changes

  • First commit: add analysis_process_step_node_fk ON analysis_process_steps(node_rowid). schemaSQL creates it on open, so existing stores get it without a schema version bump. The planner now also picks it for the generation-and-node lookup, and TestAnalysisGenerationQueryPlansUseBoundedIndexes now asserts that this lookup still searches on both columns.
  • Second commit: still choose the retained generations newest-first (keep), but delete eligible ones oldest-first, so a partly collected generation is finished first.
  • A pass that ends on its own per-run deadline is retried by the one background worker, with backoff from 30 s doubling to 5 minutes. The worker keeps the prune slot while retrying, and new requests coalesce into it. Other errors, including a DeadlineExceeded that is not from the per-run context, are not retried.
  • DrainBackground cancels a running pass and a pending backoff.
  • Chunk locking, the per-chunk eligibility checks and the exclusion of the active and building generations are unchanged.

Testing

  • All tests pass (go test -race ./...)
  • New tests added for new functionality
  • Benchmarks run if performance-relevant
  • go test -race -run AnalysisGeneration ./internal/graph/store_sqlite/ ./internal/mcp/ and golangci-lint run on both packages pass. The first commit passes the store_sqlite tests on its own.
  • With the original GC and scheduler code, TestAnalysisGenerationGCInterruptedPruneResumesOldest, TestAnalysisGenerationPruneRetriesDeadlinesUntilComplete and TestAnalysisGenerationPruneDrainCancelsRunAndBackoff fail. Without the new index, the plan test reports SCAN analysis_process_steps for the foreign-key lookup.
  • A standalone modernc.org/sqlite program with the same table and index shape and 200,000 process-step rows took 1.02 s to delete 100 nodes of another generation without the index, and 1.4 ms to delete 1000 with it. It is not part of this PR.

Checklist

  • Code follows existing patterns in the codebase
  • No unnecessary abstractions added
  • Language extractor includes Meta["methods"] for interfaces (if applicable)
  • Methods have EdgeMemberOf edges to their containing type (if applicable)

…key checks

analysis_process_steps.node_rowid references analysis_nodes(id) with
foreign keys on, but the only index containing node_rowid leads with
generation_id. Deleting an analysis_nodes row therefore scanned every
generation's process steps, so a 1000-row GC chunk could take longer than
the 30 s prune deadline on a large store and never commit. Add an index led
by node_rowid. schemaSQL creates it on open, so existing stores get it
without a schema version bump.

The planner now also picks this index for the generation-and-node lookup;
the plan test asserts that it still searches on both columns.
…imited runs

A prune pass has a 30 s deadline and deleted eligible generations
newest-first, so on a large backlog each pass was interrupted and the next
one started on a different generation. Keep choosing the retained
generations newest-first, but delete eligible ones oldest-first so a partly
collected generation is finished first. When a pass ends on its own
deadline, the background worker retries it with backoff from 30 s doubling
to 5 minutes and keeps the prune slot; new requests coalesce into it.
Other errors are not retried. DrainBackground cancels a running pass and a
pending backoff. Chunk locking, per-chunk eligibility checks and the
active/building exclusions are unchanged.
@peterkc

peterkc commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

@zzet zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks, both root causes are right, and the scheduler tests are thorough. I mutation-checked oldest-first and the deadline retry, and each one fails its test when undone.

Please drop the old index. analysis_process_steps is WITHOUT ROWID, so analysis_process_step_node_fk(node_rowid) implicitly carries (generation_id, process_id, ordinal). On a 2.8M-row synthetic table with only the new index, both lookups are covering: SEARCH … USING COVERING INDEX (node_rowid=? AND generation_id=?) with no temp sort, and (node_rowid=?) for the FK check. Your plan test already shows the planner switching to it, so analysis_process_steps_by_node becomes unused. It's the same size (182 MB per 2.8M rows here), and every RunAnalysis insert still maintains it, which works against the store-growth goal. DROP INDEX IF EXISTS analysis_process_steps_by_node in schemaSQL would do it, plus updating the plan test's comment.

Optional: the retry loop has no no-progress stop. A chunk that can never commit within 30 s would retry every 5 min indefinitely. A cap on consecutive passes that commit nothing would bound that.

@zzet

zzet commented Oct 2, 2026

Copy link
Copy Markdown
Owner

If #806 merges first, I'll rebase this PR onto it. If this PR merges first, I can push the resolution to a branch for #806.

I'd be happy to avoid multiple manipulations with #806 is possible. let's rebase and ship this change after the #806

analysis_process_steps is WITHOUT ROWID, so the node_rowid index also
carries the primary key and covers lookups by (generation_id, node_rowid).
analysis_process_steps_by_node is no longer used but every insert still
maintains it. Remove it from the schema and drop it from existing stores
on open.
@peterkc

peterkc commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the plan check. Done in 84e4754:

  • analysis_process_steps_by_node is gone from the schema, and schemaSQL now runs DROP INDEX IF EXISTS analysis_process_steps_by_node, so existing stores lose it on the next open.
  • The plan test comment now says the node_rowid index covers the (generation_id, node_rowid) lookup because it carries the primary key.
  • New TestOpenDropsRedundantAnalysisProcessStepIndex creates the old index, reopens the store and checks that only analysis_process_step_node_fk remains. It fails without the DROP.

go test -race ./internal/graph/store_sqlite/ passes in full.

Agreed on waiting for #806. Once it merges, I'll rebase this PR onto it and resolve the scheduleAnalysisGenerationPrune conflict as described above.

The prune worker retries every pass that ends on its 30 s deadline. That
is correct while each pass commits chunks, but a chunk that can never
commit in 30 s would retry every 5 minutes indefinitely and hold the
store's write lock each time.

PruneAnalysisGenerations now returns the number of rows deleted by
committed chunks, also when it returns an error. The worker stops after
3 consecutive deadline-limited passes that delete nothing, and logs one
warning. A pass that deletes rows resets the count, so a slow prune that
still makes progress keeps going. The next RunAnalysis schedules a new
prune as before.
@peterkc

peterkc commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

@zzet zzet added the Blocked label Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants