Background work
Applies to The Fabrica v0.1.2. Documentation source:
1ee1bc9cb661, including post-release documentation corrections.
Worker hardening: the config bundle
backend/src/workers/celery_app.py ships a non-default Celery config.
Each setting addresses a specific failure mode. The pattern is “secure
defaults” — flipping any of these to Celery’s default is a downgrade.
Why each one
task_serializer="json" + accept_content=["json"]
Celery used pickle as the default serializer until 4.x. A malicious
broker — or any attacker who can write to Redis — can ship a payload
that the worker unpickles into arbitrary code execution. JSON
serialization makes the broker payload data-only.
Cost: you can’t ship custom Python objects as task args. Hard rule worth keeping; if a buyer needs to pass a complex object they serialize it themselves (e.g. UUID → str at the call site).
task_track_started=True
Default is False — Celery doesn’t write a “STARTED” state to the result backend. With this on, monitoring tools (Flower, Sentry’s Celery integration) can show “task is alive, working” vs “task is queued” — the difference between a worker being stuck and a worker being idle.
worker_prefetch_multiplier=1
Default is 4. Celery prefetches N×concurrency tasks from the broker per worker so the worker has work queued up locally. Default is fine for short tasks; ruins long tasks because:
- Prefetched tasks aren’t visible to other workers.
- If worker A grabs 4 tasks and one takes 30 minutes, the other 3 sit waiting — even if worker B is idle.
The factory’s tasks (reconciler, expiry, deletion) are short, but
buyer-added tasks might not be. =1 is the safe default; buyers
who add lots of small tasks can raise it.
worker_max_tasks_per_child=10
Recycle the worker process every 10 tasks. Defeats memory leaks in async loops + third-party libraries (looking at you, every SDK that caches DNS lookups forever). 10 is the safe default; buyers can raise it if task startup cost is high.
worker_cancel_long_running_tasks_on_connection_loss=True
If the broker connection dies mid-task, the broker won’t see the ACK
when the task completes — so on broker reconnect, the task gets
redelivered. With this setting, the worker gives up on the orphaned
task instead of completing it (and having it re-delivered → double
work). Pair with task_acks_late=True for at-least-once semantics
that won’t double-bill the user.
worker_hijack_root_logger=False
Celery’s default replaces the root logger config — which means your
careful FastAPI logging setup gets clobbered the moment a worker boots.
Off means our src/config/logging.py keeps owning the
logger hierarchy.
task_acks_late=True + task_reject_on_worker_lost=True
Default is “ack the task on receipt” — if the worker crashes mid-task,
the broker considers it done. With acks_late, the worker only ACKs
after task_completed. If the worker dies, the broker redelivers.
Pair with the orphan-deduct reconciler (why-credit-ledger.md) and
you have at-least-once semantics that don’t burn user credits when
the worker dies.
Operational notes
- Flower / Celery beat: same config applies — both processes
inherit
app.conf. No special setup. - CI tests: tests don’t talk to the broker directly; tasks are
invoked via
_task_func.delay(...)or directly via the async helpers exposed by each task module (so we never need a live Celery worker in CI). - Replacing the broker: if a buyer swaps Redis for RabbitMQ, the
task_serializer / accept_content / acks_latesettings are broker-agnostic and survive. The TLS hardening inget_broker_urlis Redis-specific; for RabbitMQ replace with the equivalent AMQP TLS config.
Receipts
backend/src/workers/celery_app.py
Bounded reconciler sweeps
reconcile_orphan_credits (Celery beat task, every 60 seconds) caps
its per-pass work at DEFAULT_RECONCILE_LIMIT = 200 orphan candidates.
Two reasons.
Why bound at all
A pristine system has zero orphans. The reconciler scans the DB, finds nothing, returns. Fast.
A failing system — broker outage, Celery worker crash loop, ill-timed deploy — can leave hundreds of orphan deducts in one beat interval. Without a limit:
- The reconciler’s
list_orphan_deducts_older_than(...)query returns thousands of rows. - The Python loop iterates each, issuing a refund per orphan.
- Each refund is a SQL INSERT — fast individually, but the loop holds the session open the whole time.
- The beat scheduler doesn’t fire the next reconciler tick because the previous one is still running.
- Real-time correctness: a user whose orphan deduct landed AFTER the sweep began waits until the next sweep — which is delayed.
With limit=200:
- Worst-case 200 refunds × ~5ms each = ~1 second per pass.
- Next pass fires on schedule, picks up the next 200.
- A backlog of 10k orphans clears in ~50 passes / ~50 minutes.
Trade-off: backlogged orphans take longer to refund. Mitigation: the
lazy-reconcile path on GET /billing/credits (planned — flag in
audit) reconciles a single user on-demand, so user-visible lag is
bounded by HTTP request time, not the beat cadence.
Why structured result
Old API:
@shared_task
def reconcile_orphan_credits() -> int:
return await _reconcile_async() # returned the refund countNew API:
@dataclass(frozen=True, slots=True)
class ReconcileResult:
scanned: int = 0
refunded: int = 0
skipped_lifecycle: int = 0
async def _reconcile_async(...) -> ReconcileResult: ...The Celery task entrypoint still returns the int (for compatibility with monitoring tools that surface task return values), but the async helper returns the rich shape. Why it matters:
- Tests:
assert result.scanned == 5 and result.refunded == 3is more informative thanassert result == 3. When the test fails, you see which orphans got skipped. - Observability: the beat task logs
extra={"scanned": ..., "refunded": ..., "skipped_lifecycle": ...}so you can grep “scans found 200 candidates but refunded 0” — a signal that something’s writingtask_startedafter the orphan threshold (i.e. workers are running very slowly). - Buyer extension: a buyer who wires the reconciler into a
Sentry / DataDog metric wants the
scanned / refundedsplit, not just the total.
Reading the skip count
skipped_lifecycle means: “we found N deducts that LOOKED orphan in
the initial scan, but by the time we checked again a task_started
or task_completed row had landed”. This is the race condition the
reconciler is built around — workers writing task_started slightly
after the deduct’s age crosses the orphan threshold. Healthy values:
skipped_lifecycle = 0— normal. Worker latency is well under the 5-min threshold.skipped_lifecycle > 0, refunded > 0— borderline. Workers occasionally slow. Investigate p99 task pickup latency.skipped_lifecycle >> refunded— workers consistently slow. Either raise the orphan threshold (ORPHAN_THRESHOLD_Sincredit_ledger.py) or scale the worker pool. Otherwise the reconciler is mostly doing pointless work.
Receipts
backend/src/workers/credit_reconciler.py- Repository method:
backend/src/services/repositories_async/credit_transaction.py:list_orphan_deducts_older_than
Provenance. Documentation source: 1ee1bc9cb6619c19f57766731e7884b37f515dc9 (main).
Extracted from docs/recipes/celery-hardening.md, docs/recipes/why-reconciler-limit.md.
That commit is later than the release these pages describe: it carries documentation corrections made after thefabrica-v0.1.2 went out. Nothing that changes how the product behaves landed between the two, which is what makes naming that version honest.