Skip to content

A sync or a new document waits behind the whole extraction backlog: jobs are claimed strictly by run_at #1300

Closed

Description

@QuintinBotes

What happens, and what you expected

claim_one orders by run_at only (crates/utopia-store/src/jobs.rs, ORDER BY run_at). Every job kind shares that one queue. So with a few hundred documents waiting for extraction, an hourly sync_source job and the process_document jobs for newly synced items wait until every older extract_document job has run.

On a local model that was days. No source synced for about 10 hours, and 423 newly synced items stayed pending, so they weren't searchable. A full-text rebuild re-queues its documents at the back in the same way.

Expected: cheap intake work (sync_source, process_document, embed_ontology) runs ahead of, or beside, long LLM work, so new material is searchable while the graph catches up.

How to see it

  1. Queue a few hundred documents for extraction against a slow chat model.
  2. Leave a GitHub-issues source on a 60-minute interval.
  3. SELECT kind, min(run_at) FROM jobs WHERE status='queued' GROUP BY 1; shows the sync job behind the extraction backlog. It doesn't run until the backlog drains.

Version

0.1.0-rc9 (ghcr image). The code is unchanged on dev.

How it runs

docker compose on macOS

Models

qwen3-8b through Ollama's OpenAI-compatible endpoint; bge-m3 embeddings

Ideas

Any one of these would do:

  • a priority column ordered before run_at;
  • a separate worker lane for jobs that make no chat call;
  • enqueueing intake jobs with an earlier run_at.

Locally we use a BEFORE INSERT trigger that backdates sync_source, process_document and embed_ontology by a year. It works, but the fix belongs in the queue itself.

Activity

  1. WaylandYang commented on Oct 18, 2026

    @WaylandYang
    Contributor

    Thank you for the detailed report and the reproduction query. Confirmed on dev: claim_one (crates/utopia-store/src/jobs.rs:374) orders only by run_at, and the only claim index is jobs_claim_idx (run_at) (migrations/0001_core.sql:100), so a sync_source or process_document queued now waits behind every older extract_document.

    One more thing a fix has to cover: an extraction job keeps its worker slot while it waits for the model's concurrency permit (run_worker caps at worker_concurrency, 64 by default). Ordering alone only decides who gets the next free slot, and on a slow model that slot can be a long time coming, so a small reserved lane for jobs that make no chat call may be needed alongside the ordering.

    A PR is welcome. The shape we'd lean towards is a priority derived from kind (so no call site changes), an index on (priority, run_at) WHERE status = 'queued', and a test showing a later sync_source is claimed before an earlier extract_document. Please say here whether you also plan the reserved lane before you start. Your #1304 fixes one source of the backlog and can go in on its own.

  2. QuintinBotes commented on Oct 18, 2026

    @QuintinBotes
    ContributorAuthor

    Thanks, that matches what I see. Here worker_concurrency is 8, and every worker is usually an extract_document waiting on the model permit.

    As a stopgap, a local BEFORE INSERT trigger backdates sync_source and process_document, so they are claimed first. With it in place, the hourly GitHub syncs today still ran only when a worker freed up: at 09:54, 10:59, 11:59, 13:02, 14:07, 15:11 and then 17:03, so one hour was skipped. So I agree that ordering alone isn't enough on a slow model and a small reserved lane is needed too. The shape you describe (priority derived from kind, an index on (priority, run_at) WHERE status = 'queued', and the claim test) looks right to me.

    I haven't started a PR for this; if I pick it up I'll say so here first, and say whether it includes the lane.

  3. QuintinBotes commented on Oct 18, 2026

    @QuintinBotes
    ContributorAuthor

    I'll pick this up, as two PRs so each stays one reviewable change:

    1. Priority. A priority column on jobs, set from kind (so no call site changes), an index on (priority, run_at) WHERE status = 'queued', claim_one ordering by priority, run_at, and a test that a later sync_source is claimed before an earlier extract_document. The migration takes the next free number on dev (0114 today).
    2. Reserved lane, as a follow-up. A small number of worker slots that only claim kinds which make no chat call, so a sync isn't left waiting for a worker even when every other worker is parked on the model permit.

    For "no chat call" I'd start with sync_source, process_document, hydrate_rss_entry, embed_ontology, build_vector_index, materialize_inferences and noop. Is that the list you'd use?

    One question before I write the migration: does a priority column on jobs count as a data model change that needs an ADR, or is the queue internal enough that the PR description is enough?

  4. WaylandYang commented on Oct 19, 2026

    @WaylandYang
    Contributor

    Thanks, the two-PR split is right.

    Kinds with no chat call. Your list matches what dispatch does on dev; please add memory_ingest (it embeds and re-indexes a remember document and queues its extraction). Two notes:

    • embed_ontology and memory_ingest wait on the embedding gate, not the chat gate.
    • process_document can call the OCR or transcription reader for scans and recordings. That isn't the chat gate either, and MinerU waits with Deferred instead of holding the slot.

    Everything else (extract_document, align_*, resolve_*, adjudicate_entities, govern, propose_*, explore_mappings, errata and implication reads) makes chat calls.

    ADR. No ADR is needed: the queue is internal state, not the knowledge data model or the API, so the PR description is enough. Take the next free migration number when you rebase; 0114 has since landed on dev.

    #1316. @linhongyu510 has meanwhile opened #1316, which orders by a CASE on kind in claim_one. Since you picked this up here first and the agreed shape includes the index and a claim test, let's continue with your PR. @linhongyu510, thank you for jumping on it; a review of QuintinBotes's PR would be very welcome.

  5. QuintinBotes commented on Oct 19, 2026

    @QuintinBotes
    ContributorAuthor

    Thanks, I've added memory_ingest, and the claim order is up as #1324. There's no ADR; it uses migration 0116 because #1318 holds 0115.

    One question on the lane before I write it. I'd make it two slots on top of worker_concurrency that only claim job_priority(kind) = 0: when every regular slot is taken, run_worker may still claim a no-chat job into a free lane slot. The alternative is to carve the lane out of worker_concurrency. That leaves model work worker_concurrency - 2 slots, which is nothing at 1 or 2 and would need a special case. The cost of the extra slots is that worker_concurrency no longer bounds the total: it becomes the bound on everything else, plus two. The test would sit in its own binary, since run_worker claims any queued job. With worker_concurrency at 1 and a chat job holding the slot, a sync_source queued after it still runs.

    Does adding the lane on top of worker_concurrency work for you, or would you rather it come out of it?

  6. WaylandYang commented on Oct 20, 2026

    @WaylandYang
    Contributor

    Thanks for #1324; it's in.

    On the reserved lane: on top of worker_concurrency, please. Carving the lane out breaks at 1 or 2, as you say, and the lane exists because the regular slots are parked on the model permit, which is a separate limit. Two extra slots for short intake jobs also fit comfortably in the default connection pool (32).

    A few details:

    • Keep the lane size a constant (2) rather than a new setting.
    • Lane slots claim only job_priority(kind) = 0; regular slots keep claiming anything, since the claim order already puts intake first.
    • Count lane jobs separately, so a finished lane job frees a lane slot and not a regular one.
    • Wherever worker_concurrency is described (the system settings text and the docs), say it bounds model work, plus two slots reserved for intake jobs.
    • One known limit worth a line in the PR: a long process_document (a big transcription, for example) can still hold a lane slot. That's acceptable for now.

    The test in its own binary, with worker_concurrency at 1, sounds right.

  7. WaylandYang commented on Oct 22, 2026

    @WaylandYang
    Contributor

    Fixed on dev by #1324 (intake kinds are claimed ahead of model work, migration 0116) and #1349 (two worker slots stay free for jobs that make no chat call, so a sync runs even while extractions hold every regular slot). materialize_typed was left in the model-work bucket; that follow-up is #1351 / #1358.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions