Post by @stefano

How background workers work in littleFedi

Every littleFedi instance runs a background job queue. Jobs are split by kind, but they all share one thing in common: they sit in a database table and are executed by the "main" worker pool, whose size you control with queue.workers (default min(8, GOMAXPROCS), 1 on the low_power preset).

The kinds fall into two groups:

  • Heavy, mostly CPU/DB-bound work - inbox (ActivityPub signature verification, actor fetches, home-feed fan-out) and media_cache (image decoding). These block a worker synchronously while they run.

  • Light, mostly network-bound outbound work - delivery (one signed POST to one remote inbox), its delivery_fanout expander, push_notification, and send_email. These are short and often latency-sensitive: a boost that hasn't landed yet or a push that hasn't arrived is visible to real people.

By default every worker can pick up any kind of job. That is perfectly fine for most instances. The only caveat: job priority only orders queued rows, it cannot preempt a job that is already running. So if a slow inbox or media job is occupying the single (or few) workers, timely outbound delivery, push and email can end up waiting behind it.

light_workers (default 0, disabled) spins up a second, dedicated worker pool for exactly those four light outbound kinds. It is a strict either/or, not extra capacity: with light_workers > 0, delivery, delivery_fanout, push_notification and send_email run only on this pool. The main pool stops touching them, even when idle, and there is no overflow between the two. So workers=8 + light_workers=2 gives outbound federation exactly 2 workers, not 10.

The light pool also yields to interactive requests like the main one does, but with a shorter wait (3s vs 10s) when both compete for the single foreground-recovery slot - so short, people-visible outbound jobs win that one job while every other worker keeps yielding to keep the web UI responsive.

It's totally optional.

With 0 (the default) all four kinds stay on the main pool. The extra goroutine(s) cost is negligible even on constrained hardware.

It can be useful for:

  • low_power devices (e.g. Raspberry Pi or similar): keep workers=1 for the heavy inbox/media work and enable light_workers=1 so outbound delivery, push and email keep flowing even while your single main worker is blocked on a slow inbox job. This is the recommended combination on a small box.

  • Instances where inbox/media loads spike: a burst of incoming activity (a big follower purge, backlog of signature-verification work) can stall time-sensitive outbound delivery on a shared pool; giving the light kinds their own pool decouples them.

  • Visibility matters: push notifications and federation delivery are things users notice immediately; if you care about their latency on a busy instance, the dedicated pool is a cheap way to protect it.

Running a single pool is the right default for most instances. Also note both pools share the DB connection limiter (sized from MaxOpenConns): the database is the serial resource, so worker counts beyond the connection count buy nothing, extra workers just deepens the queue in front of it. Size light_workers for your real outbound throughput.

#littleFedi