A Newbie's Guided Tour
and how data flows from source to semantic search.
Ingests product records from MongoDB + Kafka, transforms via idempotent handler chain, makes sparse (BM25) + dense (OpenAI) embeddings, upserts vectors to Qdrant for semantic search.
Processing millions of product records requires a pipeline that is idempotent, resumable, and crash-safe.
If a worker dies mid-chain, the system must pick up exactly where it left off — no duplicates, no orphans.
Multiple consumers compete for jobs. The system needs distributed locks, atomic claims, and cursor-based reconciliation.
No two workers should process the same record. Stale claims must be detected and re-queued automatically.
Core is a framework with ZERO business logic.
Extensions contain ALL domain work.
Run, Process, Connector
Pure framework — no domain knowledge
Qdrant writes, Dedup rules
All business logic lives here
(atomic)
(fetch Mongo)
(RabbitMQ)
(pick job)
PG + Redis
Finalize
(picks chain)
- No RabbitMQ — Kafka already buffers messages in partitions. No need for a separate job queue.
- No run finalization — there is no "run" concept. Each message is independently processed and offset-committed.
- LISTENING state — the process stays in LISTENING indefinitely until force-completed by an operator.
- Offset = cursor — Kafka's consumer offset acts as the resume point. Crash-safe by design.
| Term | Definition |
|---|---|
| run | A top-level execution: batch or stream. Has a state machine and lifecycle. |
| flow | Orchestrates a run — creates it, drives state transitions, handles finalization. |
| process | A long-lived worker instance that claims and executes runs or stream listeners. |
| connector | Adapts an external system (Mongo, Kafka, Qdrant) into the framework's interface. |
| chain | An ordered sequence of handlers that transform a single record end-to-end. |
| handler | One stage in a chain. Receives ChainState, does work, returns updated state. |
| Term | Definition |
|---|---|
| contract | Declares which handlers a chain must include — required, first, last, unique constraints. |
| router | Selects which chain to use for a given message based on operation type or metadata. |
| dedup L1 | Fast content-hash check in Redis. Skips records whose content hasn't changed. |
| dedup L2 | Version-based check in the handler chain. Compares source version vs. last-processed version. |
| cursor | Bookmark for pagination (Mongo) or offset (Kafka). Enables crash-safe resume. |
| point ID | Deterministic Qdrant vector ID derived from record_id. Makes upserts idempotent. |
A Chain is an ordered list of BaseHandlers that share a single ChainState object. The state threads through every handler — each one reads, mutates, and passes it forward.
Chain.of() assembles the chain from handler providers. Chain.run() executes them sequentially, catching errors at each stage.
The @handler_provider decorator marks extension classes as chain participants. The core never knows what the handlers do — it only knows the interface.
→ Next handler in chain continues.
→ Skip to StatusHandler (last).
→ Re-raise to consumer.
→ Job marked FAILED.
| Error | Meaning | Chain behavior |
|---|---|---|
| DedupSkipError | Record content unchanged (L1 or L2 dedup) | SKIP jump to StatusHandler |
| StaleVersionError | Source version ≤ last processed version | SKIP jump to StatusHandler |
| PermanentError | Unrecoverable — bad data, missing config | FAIL re-raise, job fails |
| TransientError | Retryable — network timeout, rate limit | RETRY consumer re-queues |
A contract declares the structural rules every chain must satisfy. The core validates chains at assembly time — before any record is processed.
- required Handlers that must be present (e.g., dedup, embed)
- first The handler that must run first (guard)
- last The handler that must run last (status)
- unique Handlers that cannot appear twice
A router selects which chain to execute for a given message. Different operations (create, update, delete) may use different chains.
The StandardChainRouter maps operation types to chain instances via a simple dictionary lookup.
Extensions define the mapping. The core only provides the interface.
| Field | Filled by |
|---|---|
| record_id | Producer (from source) |
| content_hash | NormalizeHandler |
| embedding_text | NormalizeHandler |
| chunks | EnrichHandler |
| sparse_vectors | EmbedHandler |
| dense_vectors | EmbedHandler |
| qdrant_payload | EnrichHandler |
| record_state | StatusHandler |
- PENDING Run created, waiting for a worker to claim it.
- PUBLISHING Producer is fetching records and enqueuing jobs to RabbitMQ. No consumers yet.
- PROCESSING All jobs published. Consumers are picking up and running chains.
- COMPLETED All jobs acked. _try_finalize_run confirms done.
- FAILED Unrecoverable error or too many retries.
- PENDING Stream process created, waiting for a worker to claim it.
- LISTENING Consumer is reading from Kafka partitions. Stays here indefinitely — processing messages as they arrive.
- COMPLETED Only via force-complete by an operator. No natural end — streams are infinite.
- FAILED Consumer crashed or Kafka connection lost beyond retry.
The Producer publishes ALL jobs before PROCESSING begins, so progress tracking (terminal ≥ published) is an honest invariant — not a guess.
If producing and consuming happen simultaneously, you cannot know total job count ahead of time.
- Progress "5 of ?" — denominator unknown
- Completion Can't tell if producer is done or just slow
- UI Progress bar is a guess, not a fact
- Race Consumer may finish before producer enqueues all jobs
Producer publishes all N jobs, then state transitions to PROCESSING. Now total is known.
- Progress "5 of 1,247" — exact denominator
- Completion _try_finalize_run checks: terminal_count ≥ published_count
- UI Progress bar is a fact
- Invariant When all published jobs are terminal → run is done
One flow → many workers
Horizontal scaling without coordination — Postgres does the locking.
Each flow can be consumed by multiple workers simultaneously. The claim mechanism is atomic at the database level — no distributed lock service needed.
claim_pending_run() uses SELECT ... FOR UPDATE SKIP LOCKED to ensure exactly one worker picks up each batch. Workers that lose the race simply try the next available batch.
Backpressure & the consumer
The consumer processes one message at a time. With prefetch_count=1, RabbitMQ won't dispatch the next job until the current one is acked.
Consumer loop:
- Receive message from queue
- Deserialize & validate payload
- Check dedup index
- Route to handler chain
- Execute chain (with timeout)
- Ack or nack based on outcome
- Emit metrics & logs
Skip conditions (ack without processing):
The supervisor reconciles
"desired_flows (code-enabled AND DB-active AND org-active) minus running = start/stop. Redis control signals, not timers."
Intersection of three conditions:
✓ Code-enabled in config
✓ DB flow marked active
✓ Org not quarantined
All three must be true for a flow to be "desired."
Compare desired set against currently running flow instances.
desired − running = to_start
running − desired = to_stop
Pure set arithmetic. No state machines.
Publish Redis control signals:
START → spawn worker task
STOP → graceful shutdown
Signals, not timers. Workers react immediately.
Stream consumer path
Kafka streams follow the same ack/commit discipline as batch flows.
Offsets are committed only after successful processing. If the chain fails with a transient error, the offset is not committed — Kafka will redeliver on reconnect.
The commit_fn callback is passed into the handler chain, giving handlers control over exactly when the offset advances.
A background task sends heartbeats every 30s to signal liveness. If heartbeats stop:
90s without heartbeat → instance marked dead
Reaper resets → uncommitted offsets redelivered
Same mechanism as batch workers — unified failure detection.
Distributed scheduling, no central scheduler
Redis SET NX as a lightweight leader election — every worker is a candidate.
Three workers all attempt redis.set_nx(lock_key, ex=3600) at the same time. Exactly one wins, two lose. The winner executes the trigger; losers back off.
No ZooKeeper, no Raft, no external scheduler. Just Redis atomicity.
| Type | Behavior |
|---|---|
| Cron | Fires at schedule (e.g., 0 2 * * *). Lock expires after run. |
| Interval | Fires every N seconds. Lock held for duration, then released. |
| Startup | Fires once on boot with random jitter (0–60s) to spread load. |
Startup jitter prevents thundering herd when multiple workers boot simultaneously.
Hold the lock for the whole run
"Release on completion, not start, else duplicate runs."
The lock is held for the entire duration of the pipeline run, not just the trigger moment. This prevents a second worker from starting a duplicate run while the first is still processing.
Release happens in on_run_completed() — a callback that fires on both success and failure.
When a run completes, its cursor position is stored. The next run inherits this cursor and continues from where the last one left off.
cursor = last processed offset/timestamp
next_run starts at cursor + 1
Prevents re-processing and gaps between consecutive runs.
Connectors are declarations, not connections
Type: Source (batch)
Adapter: MongoBatchSource
Config: uri, db, collection
Declaration says "I need MongoDB." Worker creates the actual client at runtime.
Type: Source (stream)
Adapter: KafkaStreamSource
Config: brokers, topic, group
Same pattern — declare intent, instantiate later.
Type: Sink
Adapter: QdrantSinkAdapter
Config: url, collection, dim
Sinks follow the same declaration pattern as sources.
The I/O boundary
Layered architecture — connectors sit at the edge, repos and stores below.
CORE — Keeping it Alive
Boot sequence
Six ordered steps from cold start to fully operational.
Boot is deterministic and ordered. Each step depends on the previous. If any step fails, the process exits — no partial startup, no zombie state.
The sequence ensures that by the time workers start consuming, all configuration has been validated and all state has been reconciled.
Quarantine bad orgs
"One org's error isolated to failed_orgs. Cluster keeps running. All-fail → ConfigError."
During boot, each org's config is validated independently. A failing org is added to failed_orgs set — its flows are skipped, but all other orgs proceed normally.
If all orgs fail, it's not an org problem — it's a ConfigError. The system escalates: boot aborts with a clear error pointing to the shared config.
The startup linter (7 checks)
Catch configuration errors at boot — before any data is processed.
- Org domains — every org has a valid domain configured
- Connector refs — all referenced connectors exist in registry
- Chain refs + routers — handler chains resolve correctly
- Handler registration — all handlers are importable and registered
- Contracts — input/output schemas match between chain steps
- Source invariant — each flow has exactly one source
- Directory/org match — file layout matches org structure
Three timers for three failures
Each timeout addresses a specific failure mode with a tailored recovery.
Dead instance → reap
No heartbeat for 90 seconds means the worker process is gone. The reaper cleans up its state and reassigns work.
Detection: heartbeat timeout
Stale run → reset
A run that hasn't progressed in 5 minutes is stuck. Reset it to PENDING so another worker can pick it up.
Detection: last_progress timeout
Max processing → fail
No run should take more than 24 hours. If it does, mark it FAILED — something is fundamentally wrong.
Detection: created_at timeout
Life of a kill -9'd worker
What happens when a worker process is terminated without graceful shutdown.
When the reaper finds a dead instance, it resets all its active runs:
Default: reset to PENDING — another worker will pick it up.
Exception: if restart_count ≥ 5, mark as FAILED. The record has been retried too many times — stop the loop.
| Restarts | Action | Status |
|---|---|---|
| 0–4 | Reset to pending, requeue | PENDING |
| 5 | Restart ceiling reached | FAILED |
The ceiling prevents infinite retry loops on permanently broken records. Each reset increments restart_count atomically.
5-pass retention purge
Foreign key ordering — delete children before parents, always.
The purge runs in exactly 5 passes, ordered by foreign key dependency. Deleting a parent before its children would violate constraints and crash the purge.
Each pass deletes records older than the retention window, working from leaf tables up to the root flow_runs table.
The Control Plane
How API calls become worker actions through database-first coordination
to Postgres
control signal
reconcile
Force Start with Dedup Clear
Full reprocessing by wiping caches and resetting cursor state
delete_by_flow
delete_prefix
no cursor
Reprocess
Error Classification
Every failure maps to exactly one of four outcomes
Retry + backoff
→ DLQ now
Control signals
Skip entirely
DLQ Lifecycle
Dead Letter Queue state machine with replay and escalation
relay_count++
dead
| State | relay_count | Action |
|---|---|---|
| pending | 0 | Awaiting replay |
| replaying | 0 | Republished to chain |
| pending | 1 | Re-failed, retry |
| pending | 2 | Re-failed again |
| dead | 3 | Manual intervention |
Crash-Safe Replay
Ordering guarantees that survive process crashes
to pipeline
update state
EXTENSION — Config becomes a Pipeline
A Process = a Pipeline Template
Named templates bundle connectors, chains, and flows into reusable configurations
- Source Connectors — MongoDB, Kafka
- Sink Connectors — Qdrant, Postgres
- Processing Chains — transform, embed, upsert
- Flow Definitions — batch vs stream routing
Deterministic ResourceNames
Predictable naming enables static validation before runtime
org slug + process + kind → siewert-kau-de-std-mongo-source. Deterministic names let linter validate at boot.
— ResourceNames conventionconfig.toml → Registered Objects
From frozen config to live connector and chain registries
frozen dataclass
builds + registers
connectors, chains, flows
| Connector | Flag | Status |
|---|---|---|
| MongoDB | mongo=true | enabled |
| Kafka | kafka=false | disabled |
| Qdrant | qdrant=true | enabled |
| Postgres | postgres=true | enabled |
Mongo Batch Flow
Cursor-based batch fetching through RabbitMQ to processing chain
cursor query
fetch_batch
publish
prefetch=1
8 stages
The Composite Cursor
Breaking timestamp ties with (updated_at, _id) ordering
(updated_at, _id) visits every doc exactly once by breaking timestamp ties. Saved to run_checkpoints.
— Cursor design principleCrash-Safe Resume
Checkpoint-based recovery with dedup protection
after each batch
from checkpoint
still process
doubles
Kafka Stream Flow
Continuous processing with direct chain execution
Consumer
offset
Operation-Based Routing
Message operation field selects the processing chain
| Operation | Chain | Action |
|---|---|---|
| add | upsert_chain | Transform → Embed → Upsert to Qdrant |
| update | upsert_chain | Transform → Embed → Upsert to Qdrant |
| delete | delete_chain | Remove from Qdrant by record_id |
Batch vs Stream Comparison
Two flow patterns, one architecture
| Dimension | Batch (Mongo) | Stream (Kafka) |
|---|---|---|
| Transport | RabbitMQ | Kafka |
| Run States | PUBLISHING → PROCESSING | LISTENING |
| Boundaries | Finite batches | Continuous |
| Commit | Ack message | Commit offset |
| Finalization | finalize when terminal ≥ published | force-complete only |
The 8-stage chain
Walking through each stage
Checks required fields before any processing begins.
Required: ["name"]
Fails fast with GuardRejectError if any field is missing or empty.
No side effects — pure gate check.
Computes content_hash = SHA-256 of sorted canonical fields.
Fields: name | description | url | cardData
Parses timestamp into UTC datetime.
Builds embedding_text for downstream vectorization.
Runs jsonschema validation against the normalized record.
Schema defined per-process in config.toml.
Catches type mismatches, missing required properties, enum violations.
Raises ValidationError on failure.
Decision tree — three-layer deduplication
Two workers receive the same record simultaneously.
Both check dedup → both see "new" → both process → duplicate vectors in Qdrant.
Solution: Redis claim lock acquired before any dedup check. Second worker sees the lock and skips.
redis.set_nx(claim_key, worker_id, ex=300)
TTL: 300s (5 min). If lock exists → skip immediately.
Prevents concurrent processing of the same record.
Query dedup_index for hash match.
Hash match + stored ts ≥ source ts → DedupSkipError
Stored ts > source ts → StaleVersionError
No match → fall through to L1.
Check dedup:{flow}:{record_id} key.
TTL: 90 days. Hit → DedupSkipError.
Miss → record is new, proceed to enrich.
Rewrite — clean HTML, strip noise, normalize whitespace.
Translate — optional i18n pass for multilingual corpora.
Chunk — split into semantic segments respecting boundaries.
Output: state.record.chunks — list of text segments ready for vectorization.
Chunk count varies per record → drives stable point ID generation in stage 7.
Sparse vectors — BM25 tokenization per chunk for keyword retrieval.
Dense vectors — OpenAI text-embedding-3-small per chunk for semantic search.
Both run in parallel via asyncio.gather().
Each chunk gets one sparse + one dense vector, paired by index.
Point ID = UUID from org:flow:record:hash:chunk_index → reprocessing yields identical IDs → idempotent upsert.
Each point ID is a UUID v5 derived from the composite key:
org_id : flow_name : record_id : content_hash : chunk_index
Same input → same UUID → Qdrant upsert overwrites in place.
No duplicate points, ever — even after reprocessing.
When chunk count changes (e.g., 5 chunks → 3 chunks), old points at indices 3–4 become orphaned.
Garbage collection: delete all existing points for this record, except the new ones.
delete_points_by_record(..., except_point_ids)
Runs before upsert to ensure clean state.
Close the loop — update every dedup layer
- Write record_state —
COMPLETEDorSKIPPED+chunks_count - Upsert L2 dedup_index — Postgres row with hash + timestamp (authoritative)
- Set L1 Redis key —
dedup:{flow}:{record_id}with 90-day TTL - Release claim lock — delete
dedup:claim:{flow}:{record_id}
If any earlier stage fails, status is never reached — the record remains unclaimed in the queue for retry.
Only on full success does the dedup state get written, making the record visible to future dedup checks.
| Layer | Key | TTL |
|---|---|---|
| L1 Redis | dedup:{flow}:{record_id} | 90 days |
| L1 Claim | dedup:claim:{flow}:{record_id} | deleted |
| L2 Postgres | dedup_index (flow, record_id) | permanent |
| State | record_state (record_id) | permanent |
Three pillars of idempotency
SHA-256
Detects unchanged records before any expensive processing.
Same content → same hash → skip early, save compute.
Stage 2: normalize
UUID v5
Deterministic UUIDs from composite key ensure upserts overwrite, never duplicate.
Reprocess → identical IDs → Qdrant replaces in place.
Stage 7: write
Redis SETNX
Prevents TOCTOU races between concurrent workers on the same record.
First worker claims → second sees lock → skips cleanly.
Stage 4: dedup
Reprocess any record safely — no duplicates, no data loss, no race conditions.
Pass enrich_cls=CustomHandler to register_org(). Open/Closed — extend without forking.
Each stage is a pluggable class with a default implementation.
Override only the stage that needs custom logic — the rest of the chain runs unchanged.
Registration-time injection: register_org(enrich_cls=...)
The chain engine resolves handlers from the org's registration, not from hardcoded imports.
Siewert records sometimes lack a url field.
Custom enrich handler falls back to card://{record_id} as a synthetic URL.
Then delegates to super().process() for standard enrichment.
One class override, zero chain changes
Org isolation guarantees
Registration-time checks prevent cross-org contamination
- Matching org enforcement — config.toml
orgfield must match the registration key. Mismatch → ConfigError at startup. - Domain normalization —
siewert-kau.deandsiewert_kau_deresolve to the same canonical domain. Prevents duplicate org registrations. - Global handler-name uniqueness — no two orgs can register the same handler name. Avoids routing ambiguity at the API layer.
- Bad org quarantined — a misconfigured org fails registration without affecting other orgs. The rest of the system continues normally.
Registration runs at startup, not at request time.
Every org's config is validated before the server accepts traffic.
A single bad org → ConfigError with a clear message, server refuses to start until fixed.
This is intentional: fail loud at deploy, not silent in production.
Minimum viable org
Two files. That's it.
config.toml — declares org name, flow definitions, collection mappings, schema.
__init__.py — calls register_org(api, ...) to wire the org into the framework.
Everything else — chain stages, dedup logic, embedding, Qdrant writes — is inherited from the process template.
Override only what's different. The framework handles the rest.
8-stage chain — guard through status, fully wired.
Dedup — L1/L2 + claim lock, zero config.
Embedding — BM25 + OpenAI, parallel.
Qdrant writes — stable IDs, stale-point GC.
Recovery — DLQ, retry, state machine.
That's it. Everything else is inherited from the process template.
The whole picture, one slide
chain engine state machines workers
triggers connectors bootstrap
recovery services DLQ
The invariant infrastructure that every org shares.
You don't configure it — you run on top of it.
State transitions, retry semantics, dedup protocol, vector writes — all framework-owned.
config.toml processes
batch / stream flows
8-stage chain handlers
per-org customization
The org-specific layer that plugs into the core.
Config declares what, handlers override how.
Override one stage or none — the defaults cover 90% of orgs.
The core truth, with everything learned hung on it.
Where to go next
Your onboarding reading list
Start with core concepts, then branch into the areas relevant to your work.
Each doc is self-contained with its own examples and code references.
The glossary at the end resolves any unfamiliar terms.
Every domain term, framework concept, and abbreviation in one place.
When in doubt, check here first.
core/01 — Architecture overview
core/02 — State machines
core/03 — Workers & concurrency
core/04 — Chain engine deep-dive
core/05 — Triggers & connectors
core/06 — Recovery & DLQ
core/07 — Services layer
ext/01 — Bootstrap & registration
ext/02 — Config.toml reference
ext/03 — Batch flows
ext/04 — Stream flows
ext/05 — Customization & overrides
ext/06 — Qdrant & vector storage
ext/07 — Observability & metrics
You're ready. Start with core/01 and follow the chain.