Knowledge Library sync pipeline
This package moves the Rails Knowledge Library synchronization pipeline from
GoodJob to API2/BullMQ. The rolling 12-hour production window ending
September 9, 2026 at ~10:13 UTC showed SyncKnowledgeBaseDocumentJob with
2,629 executions for 158 unique jobs and 2,488 US GoodJob throttle failures,
plus 113 SyncKnowledgeBaseDocumentChunksJob executions and residual
SyncKnowledgeBaseSourcesJob, SyncKnowledgeBaseSourceJob,
StartKnowledgeBaseCrawlJob, and CrawlKnowledgeBaseUrlJob rows. The storm
was GoodJob perform_throttle retrying each throttled job as a failure, not
provider load.
Ownership boundary
The pipeline writes Rails Knowledge Library records only:
knowledge_base_sources, knowledge_base_documents,
knowledge_base_document_sections, knowledge_base_sections,
knowledge_base_crawl_tasks, document_chunks (source_type = 'KnowledgeBaseDocument'), and pg_search_documents. API2-native
agent/workflow knowledge tables under the api2 schema are a different
product surface and are not touched by this package.
| Concern | Owner after this package | Notes |
|---|---|---|
| Daily source scan (01:00 UTC) | API2 knowledge-base-sync-daily-scan scheduler → runKnowledgeBaseSyncTick | Replaces the sync_knowledge_base_sources GoodJob cron; also sweeps kept documents with content but no chunks (30-minute minimum age) |
| Provider source sync (Zendesk, Notion, URL, Confluence, API-key Scribe) | API2 knowledge-base-sync worker → native src/lib/knowledge-base-sync/providers/* via runNativeSyncUnit | TypeScript ports of KnowledgeBaseSource::Sync::* (auth, pagination, timestamp/unchanged checks, discard/undiscard, tenant scoping); discovered document work is returned as follow-up requests and enqueued by API2 |
| Document sync | API2 knowledge-base-sync worker → native provider document runners (documents.ts saveKnowledgeBaseDocument) | Ports of KnowledgeBaseDocument::Sync::*; a content change yields one document_chunks follow-up and a first-time source-identifier fill yields one document follow-up, mirroring the Rails callbacks |
| Crawl start / per-URL crawl | API2 knowledge-base-sync worker → native providers/url-crawl.ts | HTML sanitising, same-host link discovery, LLM article extraction (copied Rails prompts), DB claim/finish ledger (api2.knowledge_base_crawl_task_urls), once-only urls_pending accounting, depth 1 / 10-URL caps; child URLs are crawl_url requests with deterministic ids per (task, url) |
| Chunking + embeddings + search index | API2 knowledge-base-chunks worker (runKnowledgeBaseChunkSyncJob) | Native: reuses the Intercom/Scribe chunker, embedder, document_chunks writer, and pg_search_documents refresh; document row is locked and discarded_at re-checked inside the transaction |
| Intercom sources/documents | Existing API2 intercom-knowledge-base-sync | Unchanged. Excluded from the scan and every Rails producer (sync_later returns early for Intercom) |
| Session-token Scribe sources (no API key) | Existing API2 scribe-knowledge-base-sync | Unchanged. Excluded from the scan and the chunk recovery sweep; API-key Scribe sources use the native providers/scribe-public.ts port with the 50/minute limit |
| Zendesk/Scribe image rehosting | API2 lib/knowledge-base/document-image-store.ts (ActiveStorage rows + object storage written natively) | Native since 2026-09-21: API2 downloads allowed-host images (Zendesk with the data-source bearer token), dedupes by checksum within the organization and mints the signed blob URL; Rails route retained |
| Manual documents | Nobody | Never synced; model callbacks still skip them |
Rails producers
Every normal-path Rails producer now calls KnowledgeBaseSyncDispatch
instead of perform_later:
KnowledgeBaseSource#sync_later→sync_sourceKnowledgeBaseDocument#sync_later/sync_chunks_later(after-commit callbacks on source-identifier and content changes) →sync_document/sync_document_chunksKnowledgeBaseCrawlTask#start_crawl_later/ child URL scheduling →start_crawl/crawl_url
KnowledgeBaseSyncDispatch posts each request to
POST /internal/knowledge_base_sync/enqueue on API2 and treats only a 202
as durable acceptance. On any non-2xx, timeout, or transport error it logs,
reports to Sentry as a warning, and enqueues the retained GoodJob job for that
unit, so no work is lost while API2 is unreachable. Provider execution no
longer passes through Rails: the native runners write the Rails tables
directly and return follow-up requests to the worker, so a BullMQ retry of a
parent unit re-derives the same deterministic child ids instead of fanning
out duplicates. The Rails KnowledgeBaseSource#sync / KnowledgeBaseDocument#sync
/ crawl-task provider code is retained only as the GoodJob rollback path.
Durability and rate limits
- Deterministic job ids:
kb-source-sync-<id>,kb-document-sync-<id>,kb-document-chunks-<id>,kb-crawl-start-<task>,kb-crawl-url-<task>-<sha256(url)[0:24]>. A request whose job is already waiting/active is a no-op; a completed/failed job of the same id is replaced so the work runs again (addReplacingFinishedJob). - Retries: three attempts, exponential backoff from 60s. A missing or
cross-tenant record → job completes as
record-missing; provider 4xx other than 429, unknown source types, and invalid credentials →KnowledgeBaseSyncUnrecoverableError→ BullMQUnrecoverableError(no retry); 429/5xx/transport → retry. A provider 404 for a single document discards that document (as Rails did) rather than failing. - Provider windows (Redis fixed-window counters, shared across workers,
keyed exactly like the retired GoodJob
perform_throttlekeys): source sync Notion 4/minute, others 20/second; document sync Notion 4/minute, Scribe 50/minute, others 3,000/minute per organization data source. A full window delays the job (moveToDelayed+DelayedError) rather than failing it, so throttling never consumes a retry attempt — the exact failure mode behind the 2,488 throttle errors. - Chunk worker: BullMQ queue limiter of 60 jobs/minute (the old
SyncKnowledgeBaseDocumentChunksJob1/second throttle), concurrency 4. - Tenant isolation: every request carries
organization_id; every native SQL statement (source/document/section/crawl-task/data-source lookups and writes) filters onorganization_id. A cross-tenant id completes asrecord-missingrather than acting. - Failure isolation: one unit per job; a failing source, document, or URL
fails only its own job. The daily tick enqueues each request under
Promise.allSettledand logs per-request failures. - Durable follow-ups: a unit’s Rails writes commit before its children are
enqueued, so a failed enqueue fails the job and the retry re-derives the
same requests from a ledger instead of trusting the provider’s “unchanged”
answer. Source runs do not advance
last_synced_atfor listed documents — the document sync does once content is stored — andrunSourceSyncappends every kept document withlast_synced_at IS NULL(Notion/Confluence/Scribe) orchunks_synced_at IS NULLto its follow-ups;runDocumentSyncre-reads its own row after every run and re-emitsdocument_chunkswhilechunks_synced_at IS NULLwith content, so a document with no source still recovers from a failed chunk enqueue on its own retry. Content changes clearchunks_synced_at, which is also what the daily recovery sweep scans. Crawl tasks keep a PostgreSQL ledger,api2.knowledge_base_crawl_task_urls(unique per(organization, task, url), withparent_url,depth,finished_at): the claim row and theurls_pendingincrement commit in one transaction, andfinished_atplus the decrement/completion commit in one transaction (success, 4xx, or the final BullMQ attempt — never on a retryable failure), so no failure window exists between “marked finished” and “counted”. A retried parent re-emits the children it claimed, and a re-run finished URL replays its follow-ups without fetching or decrementing again, sourls_pendingcannot strand, go negative, or complete early. - Snapshot-safe removal: Zendesk discards documents/sections only against a complete listing. A failed article page aborts the run (job retries, nothing removed); a failed translation fetch keeps that article’s known locales and skips the removal pass for the run.
Pause and rollback
- Kill switch: Flipper
pause_api2_knowledge_base_sync(read by both Rails and API2). Owner: engineering. Purpose: rollback for the Knowledge Library pipeline migration. Created 2026-09-09; review/remove by 2026-12-08 after bake-in. Safe state is off (API2 owns the pipeline).- Rails: with the flag on,
KnowledgeBaseSyncDispatchenqueues the retained GoodJob jobs directly and never calls API2. - API2: the daily tick and both workers re-check the flag at execution
time; queued jobs complete as
skipped/pausedrather than racing the Rails path.
- Rails: with the flag on,
- Cron:
sync_knowledge_base_sourcesstays ingood_job.rbwithenabled_by_default: false. Rollback = enable the flag, then enable the cron from the GoodJob dashboard (or revert the line). - Retained jobs: all six
*KnowledgeBase*Jobclasses remain deployed with their original throttles, so already-enqueued rows drain and the pause fallback works.
Re-driving work after a pause
Skipped BullMQ jobs do not resume on their own. Once the flag is off again:
- The next 01:00 UTC tick re-scans every source and sweeps documents with content but no chunks, which covers the common case.
- To catch up immediately, run the scan by hand from an API2 shell:
await runKnowledgeBaseSyncTick()fromdist/lib/knowledge-base-sync/tick.js, or enqueue specific units viaPOST /internal/knowledge_base_sync/enqueuewith the internal bearer token and arequestsarray. - From Rails,
KnowledgeBaseSyncDispatch.sync_source(source)(orsync_document,sync_document_chunks,start_crawl) re-dispatches a single unit through the normal path.
Production observation
Expected after cutover in each region: zero new normal-path rows for the
six classes (rows inside the 12-hour retention window created before the
deploy are historical), a bounded knowledge-base-sync queue that drains
after the 01:00 UTC tick, no DelayedError-induced failures, and existing
GoodJob rows completing or being recoverable.
-- New GoodJob rows since the deploy (should be 0 unless the pause flag is on)
SELECT job_class, count(*) AS jobs,
min(created_at) AS first_seen, max(created_at) AS last_seen
FROM good_jobs
WHERE job_class IN (
'SyncKnowledgeBaseSourcesJob', 'SyncKnowledgeBaseSourceJob',
'SyncKnowledgeBaseDocumentJob', 'SyncKnowledgeBaseDocumentChunksJob',
'StartKnowledgeBaseCrawlJob', 'CrawlKnowledgeBaseUrlJob')
AND created_at > '<deploy timestamp UTC>'
GROUP BY job_class ORDER BY jobs DESC;
-- Throttle-storm check: executions vs unique jobs in the retained window
SELECT j.job_class, count(e.*) AS executions, count(DISTINCT j.active_job_id) AS unique_jobs,
count(*) FILTER (WHERE e.error ILIKE '%throttle%') AS throttle_failures
FROM good_job_executions e JOIN good_jobs j ON j.active_job_id = e.active_job_id
WHERE j.job_class LIKE '%KnowledgeBase%' AND e.created_at > now() - interval '12 hours'
GROUP BY j.job_class;
-- Recovery-sweep backlog: kept documents with content but no chunks
SELECT count(*) FROM knowledge_base_documents
WHERE discarded_at IS NULL AND chunks_synced_at IS NULL
AND content IS NOT NULL AND content <> '' AND source_type <> 'intercom';
-- Sources still failing after the daily scan
SELECT id, organization_id, type, sync_status, last_synced_at
FROM knowledge_base_sources
WHERE type <> 'intercom' AND (sync_status = 'failed' OR last_synced_at < now() - interval '2 days')
ORDER BY last_synced_at NULLS FIRST LIMIT 50;Flipper state: Flipper.enabled?(:pause_api2_knowledge_base_sync) in a
Rails console (expected false). BullMQ state: knowledge-base-sync and
knowledge-base-chunks queue depths and delayed counts in the API2 queue
dashboard; a growing delayed count on a Notion-heavy tenant is expected
throttling, a growing failed count is not.
Bake and rollback gates
- Deploy both services; confirm the daily tick ran once per region
(
knowledge-base-syncworkerscan donelogs, sources’last_synced_atadvancing) and that the 01:00 UTC GoodJob cron did not run. - 24 clean hours: zero new normal-path GoodJob rows for the six classes,
zero
Api2KnowledgeBaseSyncEnqueuer::DeliveryErrorSentry warnings that indicate fallback traffic, no duplicatedocument_chunkssets, and no cross-tenant Sentry events. - Roll back immediately (flag on, cron enabled) if the fallback warning fires continuously, chunks stop being written, or provider limits are exceeded upstream (Notion/Scribe 429s).
- After a clean bake in both regions, remove the flag branch, the cron entry, and the six GoodJob job classes in a follow-up.
Verification
API2: unit tests for enqueue mapping/dedupe/deterministic ids, job execution
(pause re-check, provider windows, delayed-not-failed throttling, 404/4xx/5xx
handling, follow-up enqueue), tick fan-out and failure isolation, and the
internal enqueue route (auth, validation, no 202 on Redis failure); PGlite
tests for the candidate/recovery SQL, tenant-checked rate-limit scopes,
section-path loading, and the chunk replacement transaction (idempotent,
tenant-scoped, search index refreshed). Rails: dispatcher specs (API2 handoff
with organization scope, no GoodJob rows on success, pause and delivery
fallbacks) and image-endpoint specs. Native providers are covered by
providers/providers.db.test.ts (PGlite Rails tables): Notion cursor
pagination/auth/reclaim/discard/rendering/4xx-vs-429, Zendesk brands,
translations, recursive sections, locale fallback, markdown; API-key Scribe
offset pagination and session-token no-op; URL crawl sanitising, same-host
links, depth/URL caps, urls_pending completion, replayed parents and
finished URLs, once-only decrement on 4xx/final attempt, Zendesk failed
first/later page and translation fetches leaving old content in place; and
run.ts status transitions plus the enqueue-failed-then-retry replay of
document/chunk follow-ups. pnpm typecheck, pnpm test, and scoped
RuboCop are required before push.