Skip to content

feat(daemon): run Notion and Web sync in a worker - #2123

Open
aaf2tbz wants to merge 5 commits into
mainfrom
feat/2051-source-ingestion-worker
Open

aaf2tbz wants to merge 5 commits into
mainfrom
feat/2051-source-ingestion-worker

Conversation

@aaf2tbz

@aaf2tbz aaf2tbz commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Source provider sync ran on the daemon's request-serving event loop. runSourceIndexJob in routes/sources-routes.ts called provider.sync directly, so Notion response parsing, unknown-tag rewriting, and hashing, and Web linkedom plus Defuddle extraction, all ran inline with request handling. Network I/O was async and DB work already went to the DB owner, but the CPU work did not.

This PR adds the worker boundary from #2051 and moves the Notion and Web providers behind it. GitHub and Discord still sync inline (see Notes), so this uses Refs #2051, not Fixes.

Changes

  • source-sync-worker.ts / source-sync-worker-handle.ts / source-sync-worker-protocol.ts: a dedicated worker thread per sync job. The layout follows the embedding worker (entry, handle, protocol). The lifecycle follows native-memory-source-worker.ts and the Pi agent worker: versioned frames, ready handshake with a 15s deadline, embedded or bundled path resolution, and terminate on settle.
    • Durable writes. The worker never opens the workspace database. On startup it registers a relay DbOwnerClient (registerDbOwnerRelay in db-owner-runtime.ts), so the providers' existing dbOwnerQuery, dbOwnerTransaction, and source artifact upsert/index/purge calls are forwarded to the daemon's existing DB owner. No second owner process is started. The relay accepts only query, batch, transaction, and the four source_artifact_* kinds, with at most 8 in flight. A full relay returns DB_OWNER_QUEUE_FULL, so the existing admission retry in submitWithAdmission still applies. Owner errors keep their codes across the boundary.
    • Secrets. The worker does not load the secret store or keyring. SourceProviderSyncContext now carries getSecret. Inside the worker, that call goes back to the daemon, which resolves only the source's configured tokenRef and rejects any other name.
    • Cancellation. The handle polls the job's shouldContinue and sends cancel to the worker, which flips the provider's shouldContinue. Notion already aborts in-flight requests on that signal. Once a cancel is sent, in-flight owner jobs are cancelled and new owner requests are rejected, so a cancelled sync cannot keep writing during deletion. If the worker has not settled 5s after the cancel, it is terminated. That covers Web's in-flight fetch.
    • Progress, logs, outcomes. Progress events and provider log entries are relayed back. Logs go through a new Logger.forwardTo, so worker warnings reach the daemon log and the live stream. The job runner keeps its existing outcome handling (complete / error with a failure count, telemetry, failure artifacts written by the provider).
    • Crash and shutdown. A worker error or exit rejects the run (source sync worker exited with code N) and the job fails. Nothing falls back to in-process execution. stopSourceIndexJobs now calls closeSourceSyncWorkers(), which terminates live workers and waits for them to be reaped before waiting on runs.
    • Bounded IPC. Worker-to-daemon frames are measured in the worker against a 4 MiB cap, the same limit as the native source worker. Owner results returned to the worker are the owner client's own results, so the DB owner's 1 MiB per-statement result cap still bounds them. Error text is capped at 16 KiB. If a sync result would exceed the cap (for example thousands of Notion failures), only the IPC frame is trimmed: every failure is kept so the job still reports the true count, the first failure (used for telemetry classification) is kept whole, and the rest lose metadata and have their messages truncated. Failure artifacts are written inside the worker before the result is sent, so they keep full detail.
  • notion-source-provider.ts, web-source-provider.ts: the adapters drop sync and set syncInWorker: true. The sync functions are exported for the worker and for tests. The job runner dispatches syncInWorker providers only to the worker, so the inline path for these two providers no longer exists.
  • github-source-provider.ts, discord-source-provider.ts: resolve tokens through context.getSecret instead of importing secrets. Behavior is unchanged. Only the shifted siteTokens were regenerated.
  • Packaging: source-sync-worker is added to platform/daemon/build.ts, scripts/build-native-bun.ts worker entries, and DAEMON_JS_WORKER_FILES. worker-process-boundary.test.ts now lists the new in-process helpers.
  • Event-loop baseline and audit report regenerated. The only new site is the bundled-path existsSync, the same pattern as the native source worker. The legacy sync DB count is unchanged at 144.
  • web/docs/src/content/docs/sources.md: new "Sync execution" section.

Type

  • feat — new user-facing feature (bumps minor)
  • fix — bug fix
  • refactor — restructure without behavior change
  • chore — build, deps, config, docs
  • perf — performance improvement
  • test — test coverage

Packages affected

  • @signet/core
  • @signet/daemon
  • @signet/cli / dashboard
  • @signet/sdk
  • @signet/connector-*
  • @signet/web
  • predictor
  • Other: scripts/build-native-bun.ts worker entries

Screenshots

N/A, no UI changes.

Migration Notes (if applicable)

No DB migration.

Testing

  • bun test passes (focused: source-sync-worker.test.ts (12), routes/ (all), provider tests for Notion/Web/GitHub/Discord (87), worker-process-boundary, logger, scripts/audit-event-loop-contract.test.ts, surfaces/cli/src/lib/runtime.test.ts)
  • bun run typecheck passes (pre-commit hook, all workspaces)
  • bun run lint passes (biome check on changed files, comments:check)
  • Tested against running daemon
  • N/A

source-sync-worker.test.ts runs the real worker module in a real worker thread against a real DB owner process:

  • Notion sync, with fetch stubbed inside the worker, writes its artifact through the relayed owner and authenticates with the relayed tokenRef. The ready thread ID differs from the test thread.
  • Web fetch, linkedom, and Defuddle extraction run in the worker and commit the page artifact.
  • While the worker is CPU-bound for 750 ms, the test thread's 5 ms timer never gaps by 250 ms or more.
  • Protocol-level cases: a secret request for a different token ref is rejected; owner kinds outside the allowlist (agent_remove) are rejected; a worker process.exit(17) fails the run; cooperative cancel resolves before the grace period; an owner write the worker submits after cancel is rejected and never commits; a 10,000-failure result that is over the cap is trimmed to fit while keeping all 10,000 entries and the first failure intact; a worker that ignores cancel is terminated; closeSourceSyncWorkers() terminates live workers.

I also bundled source-sync-worker.ts with the daemon's Bun.build settings and ran the Notion case against the bundled output. It passed. The full platform/daemon build could not run locally because the connector workspaces are not built in this checkout.

The new route tests in sources-routes.test.ts:

  • Web and Notion jobs reach the injected worker runner and never the inline provider.
  • A worker death leaves the job in error with the exit message.
  • Disconnecting a source cancels the worker sync.
  • A real Notion worker sync with 100 failures whose untrimmed result is over 4 MiB still ends with notion source sync completed with 100 failure(s). Telemetry reports failed: 100, and all 100 failure artifacts keep their full text. On the previous commit this job instead failed with the IPC-limit error.

Before this change the inline provider.sync ran instead, so these tests fail against main.

Not run locally: a full daemon lifecycle on an installed build. Three db-owner-client.test.ts cases (sqlite-vec / SIGABRT) and three harness tests fail here identically on main because of the local SQLite and unbuilt-connector setup.

Live verification. I ran the daemon from source at this PR's head (1550699) and from main (ea8e8b5). Each daemon had its own isolated workspace, HOME, and port. A local stub site served a 4.6 MB HTML page. It was bound only to the machine's own global IPv6 address, because the Web provider rejects loopback and private targets, and it refused every other client. Each source was added through POST /api/sources/web. A separate client polled /health/live every 20 ms during the sync.

  • Responsiveness. On this PR the heavy-page sync took 29–35 s and wrote its source_web_page artifact. Max /health/live latency during the sync was 8.9 ms (3.8 ms on a second run), with p99 ≤ 2 ms, and nothing failed. On main the same sync blocked one /health/live request for 33.3 s. Each heavy sync on main also logged Event loop critically blocked (~30 s) and Event-loop stall latched, and DB-owner jobs hit their deadline (Vector backfill probe failed … exceeded its deadline). On this PR, none of these appeared across 10 heavy-page sync runs (7 completed, 3 interrupted). No second DB owner process was started: the daemon kept the same 2 owner children during a sync.
  • Delete mid-sync. I sent DELETE /api/sources/:id 5 s into the heavy extraction. On this PR it returned 200 in 48 ms. The worker's threads were torn down about 5 s later (cancel grace), and the thread count was back to idle about 15 s after the delete. No rows with that source_id remained, and re-adding the same URL immediately ran a new job to complete. For a deletion during a slow, streaming fetch, the stub saw the client drop the connection 5.2 s after the DELETE. On main the DELETE response took about 26 s to arrive, because the event loop was busy with the extraction.
  • Shutdown mid-sync. SIGTERM during the heavy extraction exited cleanly in 0.23 s with exitCode: 0. Both DB owner children were gone, and no process from the scratch root was left. After a restart, the source was still configured with no stale job and zero partial rows. The only warnings, synthesis worker exited with code 1 and a Vector backfill probe failed … draining race at shutdown, also appear on main.
  • Notion. The Notion API base URL is hard-coded (NOTION_API_BASE), so I didn't run a live Notion sync against a stub. I added a Notion source whose tokenRef had no stored secret. The worker asked the daemon for the secret, and the job ended in error within 170 ms with Failed to resolve Notion token ref '…': Secret '…' not found. No network request was made. Rejection of a different token ref is covered only by the unit test.
  • Memory. After 12 back-to-back small syncs, the daemon's phys_footprint stayed flat at 253 MB, and threads settled back to the idle count.

AI disclosure

  • No AI tools were used in this PR
  • AI tools were used (see Assisted-by tags in commits)

Notes

Design decisions the issue left open:

  • Owner protocol across the boundary. The worker relays owner operations through the daemon thread instead of holding its own owner client. Holding its own client would start a second owner process for the workspace database, which the architecture contract forbids.
  • One worker per job, not a long-lived shared worker. A crash then fails exactly the job that caused it, and there is no cross-job state to reset. The cost is one worker startup per sync.
  • Secrets. Secrets are resolved on the daemon thread, mirroring how the Pi agent worker receives its API key. The worker can only obtain the source's own tokenRef.

What remains for #2051:

  • GitHub and Discord still run inline on the daemon runtime. Their DB writes use getDbAccessor().withReadDbAsync/withWriteTxAsync closures (3 sites in GitHub; 6 in Discord plus 2 in discord-desktop-cache-source.ts), which cannot cross a thread. They must become owner operations first. GitHub's stale-artifact read can approach the 1 MiB owner result cap at maxItemsPerRepo 10,000, so it needs paging. Discord also needs its gateway-tail socket lifecycle and the synchronous Desktop-cache file reads moved. Once both providers move, SourceProviderAdapter.sync (the inline path) can be deleted.
  • File and document import extraction (document-import-service.ts, import-normalizer.ts, routes/import-routes.ts) has not been audited or moved.
  • Transcript capture (transcript-capture-worker.ts) is unchanged.

Summary by CodeRabbit

  • New Features
    • Notion and Web source sync now run in dedicated background workers, helping keep the daemon responsive during indexing.
    • Sync jobs can be cancelled when a source is disconnected. Workers that do not stop promptly are terminated.
    • Worker failures are reported as failed jobs, with no automatic in-process retry.
  • Documentation
    • Updated source documentation to explain sync execution, cancellation, and failure behavior.

Source provider sync ran inline on the daemon's request-serving event
loop: runSourceIndexJob called provider.sync directly, so Notion JSON
parsing, unknown-tag rewriting and hashing, and Web linkedom/Defuddle
extraction all blocked request handling for corpus-sized work.

Add a dedicated source sync worker thread, one per job, following the
native source worker and Pi agent worker patterns. The worker hosts the
Notion and Web sync implementations. It never opens the workspace
database or the secret store: DB owner requests are relayed through the
daemon thread to the existing DB owner (bounded allowlist, at most 8 in
flight), and secret lookups are limited to the source's configured
tokenRef. Progress and log entries are relayed back. Cancellation is
polled from the job state, delivered to the worker, rejects further
owner requests, and terminates the worker after a 5s grace. A worker
crash or exit fails the job explicitly with no in-process fallback, and
daemon shutdown terminates and reaps live workers.

The Notion and Web adapters no longer expose an inline sync, so the job
runner can only reach them through the worker. Provider sync contexts
now carry getSecret so secret resolution stays with the daemon. GitHub
and Discord still sync inline; their DB writes use legacy accessor
closures that must move to owner operations before they can run in the
worker.

Refs #2051

Assisted-by: Claude-Code:claude-opus-5-5
@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 66cc5804-70fc-4893-bcd8-2ee15fc08ceb
📥 Commits

Reviewing files that changed from the base of the PR and between ea8e8b5 and 077073a.

📒 Files selected for processing (24)
  • docs/event-loop-contract-audit.md
  • platform/daemon/build.ts
  • platform/daemon/src/db-owner-runtime.ts
  • platform/daemon/src/discord-source-provider.test.ts
  • platform/daemon/src/discord-source-provider.ts
  • platform/daemon/src/github-source-provider.test.ts
  • platform/daemon/src/github-source-provider.ts
  • platform/daemon/src/logger.ts
  • platform/daemon/src/notion-source-provider.test.ts
  • platform/daemon/src/notion-source-provider.ts
  • platform/daemon/src/routes/sources-routes.test.ts
  • platform/daemon/src/routes/sources-routes.ts
  • platform/daemon/src/source-providers.ts
  • platform/daemon/src/source-sync-worker-handle.ts
  • platform/daemon/src/source-sync-worker-protocol.ts
  • platform/daemon/src/source-sync-worker.test.ts
  • platform/daemon/src/source-sync-worker.ts
  • platform/daemon/src/web-source-provider.test.ts
  • platform/daemon/src/web-source-provider.ts
  • platform/daemon/src/worker-process-boundary.test.ts
  • scripts/build-native-bun.ts
  • scripts/event-loop-contract-baseline.json
  • surfaces/cli/src/lib/runtime.ts
  • web/docs/src/content/docs/sources.md

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Notion and Web source synchronization can run in dedicated worker threads. The daemon relays database-owner operations, configured secrets, progress, and logs. The changes add worker lifecycle handling, provider integration, tests, build support, and documentation.

Changes

Worker-backed source synchronization

Layer / File(s) Summary
Worker protocol and relays
platform/daemon/src/source-sync-worker-protocol.ts, platform/daemon/src/db-owner-runtime.ts, platform/daemon/src/logger.ts
A versioned message protocol defines sync, owner, secret, progress, log, result, and error messages. The DB owner can register a relay client, and the logger can forward entries to a sink.
Worker execution and lifecycle
platform/daemon/src/source-sync-worker-handle.ts, platform/daemon/src/source-sync-worker.ts, platform/daemon/src/source-sync-worker.test.ts
The host and worker relay sync jobs, database-owner requests, secrets, progress, and logs. The host validates messages and results, handles cancellation and shutdown, and terminates workers. Tests cover sync behavior, responsiveness, relays, errors, cancellation, and shutdown.
Provider and indexing integration
platform/daemon/src/source-providers.ts, platform/daemon/src/routes/sources-routes.ts, platform/daemon/src/routes/sources-routes.test.ts, platform/daemon/src/*-source-provider.ts, platform/daemon/src/*-source-provider.test.ts
Sync contexts now provide getSecret, and adapters can opt into worker execution. Notion and Web opt in. Indexing routes pass the worker runner and use it for opted-in providers. Provider and route tests use the updated contexts and cover worker completion, errors, and cancellation.
Build, verification, and documentation
platform/daemon/build.ts, scripts/build-native-bun.ts, surfaces/cli/src/lib/runtime.ts, platform/daemon/src/worker-process-boundary.test.ts, scripts/event-loop-contract-baseline.json, docs/event-loop-contract-audit.md, web/docs/src/content/docs/sources.md
Build and bundle checks include the source-sync worker, and boundary tests audit the host and worker. Documentation describes worker execution and cancellation. Event-loop inventory counts and source-location references are updated.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant SourceIndexJob
  participant SourceSyncHost
  participant SourceSyncWorker
  participant DbOwnerRelay
  SourceIndexJob->>SourceSyncHost: Start Notion or Web sync
  SourceSyncHost->>SourceSyncWorker: Send sync job
  SourceSyncWorker->>SourceSyncHost: Request database-owner operation
  SourceSyncHost->>DbOwnerRelay: Submit permitted request
  DbOwnerRelay->>SourceSyncHost: Return result or error
  SourceSyncHost->>SourceSyncWorker: Return relay response
  SourceSyncWorker->>SourceSyncHost: Send sync result or error
  SourceSyncHost->>SourceIndexJob: Complete or fail indexing job
Loading

Suggested reviewers: nicholaivogel

Merge Risk: ⚪ Minimal · up to 07707

Notion and Web syncs now run in worker threads, so provider parsing no longer blocks the daemon's event loop. No concrete merge-blocking issue was found. The author notes that the full daemon lifecycle on an installed build was not tested.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 42 functions across 21 files. (3 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the main change: moving Notion and Web sync into a daemon worker.
Description check ✅ Passed The description is complete and follows the repository template. It explains the motivation, implementation, affected packages, testing, known limitations, AI disclosure, and remaining work.
Full details: Docstring Coverage

Explanation

Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 42 functions across 21 files. (3 skipped: 3 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

comments:check rejects the block comment on runSourceSyncInWorker.

Assisted-by: Claude-Code:claude-opus-5-5
coderabbitai[bot]
coderabbitai Bot previously approved these changes Oct 8, 2026
Assisted-by: Claude-Code:claude-opus-5-5
Assisted-by: Claude-Code:claude-opus-5-5
A Notion sync with thousands of failures could produce a worker result
frame over the 4 MiB cap, so the job reported the IPC limit error instead
of the failure count. Trim per-failure details in the result frame only,
keeping the count and the first failure intact. Failure artifacts are
written in the worker and are unaffected.

Assisted-by: Claude-Code:claude-opus-5-5
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.

1 participant