Skip to content

feat(worker): Crawl each storage config as a work item, with scope routing (#281) - #293

Merged
bitbiter-dev merged 6 commits into
masterfrom
feat/281-crawl-work-item
Oct 20, 2026
Merged

bitbiter-dev merged 6 commits into
masterfrom
feat/281-crawl-work-item

Conversation

@bitbiter-dev

@bitbiter-dev bitbiter-dev commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Closes #281. Part of #276.

What changes

Crawling is now visible, controllable work: each storage config's crawl is a Crawl work item, and the Worker's own crawl timer is gone.

Scope routing

  • WorkItems.StorageConfigId (nullable, cascading on config delete) is set from a new optional IWorkPayload.StorageConfigId.
  • A per-user Worker (WORKER__USER) claims unscoped items and items of its user's configs. An all-user Worker claims everything.
  • The served user is WorkerState.ResolvedUserId, read at each claim and each scheduler tick and never cached at construction, because hosted services are built before WorkerInitializer resolves it.

Scheduling

  • WorkScheduler now asks each IWorkScheduleSource for its schedules at every tick.
  • A fixed WorkSchedule<TPayload> is its own only source, so token cleanup and retention behave as before.
  • CrawlSchedules returns one crawl:<config id> schedule per active config the Worker serves, due Worker:CrawlIntervalHours (4 h) after the last crawl of that config finished.
  • The first crawl after a start comes from due-ness, so a restart that outlasted the interval crawls straight away.

The crawl

  • CrawlHandler runs today's crawl of the config through FileIngestionPipeline, with unchanged per-file concurrency, exclusions and failure handling.
  • A missing or inactive config is skipped, and the result says why.
  • A missing root fails only that config's item, so fix(worker): One storage config with a missing root stops the whole Worker #282's isolation still holds.
  • A shutdown propagates, and the executor releases the item without a stall.
  • The pipeline counts items found, processed and failed (a Live Photo pair is one item) under one lock, and reports them as progress with no total until the walk ends. The final counts are the item's result.

Merge with #36 (decided with the maintainer)

#36 (#291) reported duplicate content once, after the crawl loop had crawled every config. That loop no longer exists, so each Crawl item now reports for its own config:

  • Within-config copies: from this Worker's tally for that config.
  • Cross-config pairs: pairs between this config and the same user's other active configs.

The counts go into the crawl's result (Duplicates), and a failed report is logged without failing the crawl. A pair across two configs is reported by each of their crawls.

Deliberately not here

Verification

  • dotnet test -p:TestSuite=All after merging master: 1,475 unit and 35 integration tests, all passing.
  • New contract tests on real PostgreSQL:
    • a per-user Worker claims only its configs and unscoped items, and an all-user Worker claims everything;
    • deleting a config removes its items and fences the running one;
    • enqueue stores the scope.
  • Unit tests:
    • the executor claims for the user resolved at that moment;
    • CrawlSchedules picks the per-user or all-user configs, with the user resolved after construction;
    • the crawl handler: skips, a failing root, shutdown, and the duplicate report and its failure;
    • pipeline counts and progress, including a failed item;
    • a second crawl over the same folder converges on the same counts.
  • Mutation testing: a scripts/mutation-changed.sh run over every production file changed before the merge reached 100.00%. The first CI sweep after the merge scored 99.86%, with two survivors in the reworked SharedContentReporter. Both are killed in ca0bb0b: the config ids are a Union, whose order a test pins, and a crawl with only cross-config pairs is now checked to be summarised.
  • mutation-guard.sh, derive-db-docs.sh --check and dotnet format --verify-no-changes pass.

🤖 Generated with Claude Code

bitbiter-dev and others added 6 commits October 19, 2026 14:20
- WorkItems.StorageConfigId, nullable, cascading on config delete, set
  from the payload's optional IWorkPayload.StorageConfigId.
- The claim takes the Worker's served user: a per-user Worker claims
  unscoped items and items of its user's configs, an all-user Worker
  claims everything. The executor reads WorkerState.ResolvedUserId at
  each claim, after WorkerInitializer has resolved it.

Contract tests on real PostgreSQL: per-user and all-user claims, a
config delete removing its items and fencing the running one, and the
scope stored on enqueue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A periodic kind can now have a varying set of schedules, such as one
crawl per served storage config. WorkScheduler asks each
IWorkScheduleSource for its schedules at every tick, inside the tick's
scope; a fixed WorkSchedule<TPayload> is its own only source, so the
token cleanup and retention schedules behave as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- WorkKind.Crawl with CrawlPayload(StorageConfigId): dedup key
  crawl:<config id>, scoped to its config.
- CrawlSchedules: one schedule per active config the Worker serves
  (its user's configs, or all of them for an all-user Worker), due
  every Worker:CrawlIntervalHours. The served user is read at each
  tick. The first crawl after a start comes from due-ness.
- CrawlHandler runs today's crawl through FileIngestionPipeline,
  unchanged in concurrency, exclusions and failure handling. A missing
  or inactive config is skipped; a missing root fails only that
  config's item (#282); a shutdown propagates and the item is released.
- The pipeline counts items found, processed and failed (a Live Photo
  pair is one item) under one lock and reports them as progress, with
  no total until the walk ends. The result is the final counts.
- The crawl BackgroundService and its timer are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d log (#281)

Deleting a storage config mid-crawl deletes its running item, so the
heartbeat is fenced without any other worker involved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict: #36 (#291) added a duplicate report after the crawl loop had
crawled every config; this branch removes that loop.
Resolved as agreed with the maintainer: each config's Crawl item runs
the report for its own config. Within-config copies come from this
Worker's tally for that config; pairs are those between it and the same
user's other active configs, so a pair across two configs is reported
by each of their crawls. The counts go into the crawl's result, and a
failed report is logged without failing the crawl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…281)

- The queried config ids are a Union, so their order is pinned by a test
  instead of being an equivalent Append/Prepend mutant.
- A crawl with pairs but no copies within the config is summarised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bitbiter-dev
bitbiter-dev merged commit fedbd85 into master Oct 20, 2026
12 checks passed
@bitbiter-dev
bitbiter-dev deleted the feat/281-crawl-work-item branch October 20, 2026 02:25
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.

feat(worker): Crawl as a work item per storage config, with scope routing

1 participant