Skip to content

The job system

Everything aizk does after a write has already returned happens through one durable queue. This page assumes you know what a scope set is and that maintenance runs as the system user, which Background work covers.

The queue is PgQueuer, which stores jobs in ordinary PostgreSQL tables and wakes workers with LISTEN and NOTIFY. We picked it over Celery, taskiq, or anything backed by Redis or RabbitMQ for one reason. A write and its follow-up job land in the same transaction, so there is no window where a chunk exists but its projection job was lost, and there is no second datastore to back up, secure and reason about. Everything already lives in one Postgres and the queue stays there too.

src/aizk/background/queue.py wraps it. Queue is an async context manager over one asyncpg connection, install_queue_schema() creates PgQueuer’s tables under an advisory lock and grants the app role only the objects PgQueuer reports installing, and run_worker() in schedule.py binds every job before calling pg.run(batch_size=settings.queue_batch_size, ...), which defaults to 64 with four times that many concurrent tasks.

QueueJob is the base every job type subclasses. A job is five class variables and one handle method.

class QueueJob[PayloadT: QueuePayload](abc.ABC):
entrypoint: ClassVar[str]
payload_type: ClassVar[type[QueuePayload]]
priority: ClassVar[int] = 0
concurrency_limit: ClassVar[int] = 0
max_attempts: ClassVar[int] = 5

The payload is a Pydantic QueuePayload that serializes to JSON bytes, so a malformed row fails validation at decode rather than deep inside a handler. A concurrency_limit of zero means unbounded. Priority comes from JobPriority in background/enum.py and larger runs first.

Priority Value Used by
maintenance 10 every scheduled pass, without exception
chunk 50 ChunkProjectionJob
artifact 75 DoclingConversionJob

Artifact conversion outranks chunk projection because a PDF that has not been converted yet produces no chunks at all, and both outrank maintenance because somebody is waiting on the first two and nobody is waiting on a nightly rebuild.

Four jobs are enqueued by application code as work arrives. MarkdownReindexJob replays chunking and embedding from stored Markdown without Docling, so it shares the conversion priority and limit.

Job Entrypoint Priority Concurrency
ChunkProjectionJob aizk_build_graph_chunk 50 graph_build_concurrency, 4
DoclingConversionJob aizk_convert_artifact 75 docling_concurrency, 4
MarkdownReindexJob aizk_reindex_artifact 75 docling_concurrency, 4
UsageAccountingJob aizk_usage_event 0 unbounded

The rest are scheduled. A ScopedScheduledJob fans out into one queue item per scope set, and all of them run at priority 10 with concurrency_limit = 1.

Job Default cron Body
ArtifactDispatchJob * * * * * re-dispatch originals a crashed handoff left pending
ChunkDispatchJob * * * * * enqueue_pending, up to 512 chunks
ProfileProjectionJob * * * * * refresh_dirty_profiles
SessionPromoteJob */15 * * * * promote_sessions
DecayJob 0 3 * * * decay, half life 90 days
DedupJob 30 3 * * * dedup_entities
CommunitiesJob 0 4 * * 0 build_communities behind the growth gate, then queues RAPTOR
RaptorJob off by default build_raptor, queued by the community pass that feeds it
ProfileRefreshJob 0 5 * * 0 refresh_profiles
InsightJob 0 7 * * 0 derive_insights

A SystemScheduledJob runs once with no scope fan-out, because its work is not tenant shaped.

Job Default cron Body
ChunkRecoveryJob * * * * * requeue 512 held chunk failures, max 3 cycles each
ArtifactIntegrityJob 0 6 * * * re-verify 100 originals older than 30 days
CleanupJob 0 1 * * * trim queue history past 7 days, then VACUUM (ANALYZE)
BackupJob 0 2 * * * scheduled_backup, off unless AIZK_BACKUP_ENABLED

Queue history is the largest table until it is trimmed

Section titled “Queue history is the largest table until it is trimmed”

PgQueuer writes one pgqueuer_log row per finished job and nothing reads it for correctness, so a busy deployment turns it into the biggest table it owns. CleanupJob.prune_pgqueuer_log deletes past cleanup_log_retention_days, 7, in batches of cleanup_log_delete_batch, 10,000, looping until a short batch ends the drain. One nightly pass clears the whole backlog, and PgQueuer indexes created, so every batch is an index scan.

The VACUUM (ANALYZE) that follows never takes an exclusive lock and never hands disk back either. PostgreSQL and storage has the one time recovery for a table that grew before the job first ran.

ScheduledJob.__init_subclass__ computes everything from the class name. CommunitiesJob becomes name = "communities", cron_entrypoint = "aizk_cron_communities" and, for scoped jobs, entrypoint = "aizk_task_communities". The expression and enabled properties then read settings.communities_cron and settings.communities_enabled. Adding a pass means subclassing, implementing execute, and adding those two settings. Nothing registers it by hand, because ScheduledJob is a patos Registry and run_worker iterates ScheduledJob.implementations().

Every enqueue passes a dedupe_key. install_queue_schema creates a partial unique index over that column restricted to the queued, picked and failed statuses, so a duplicate is rejected while a job is live or held but the same key is admitted again once the earlier run succeeded. Queue.enqueue catches PgQueuer’s DuplicateJobError and returns False rather than raising, which is what lets ChunkDispatchJob sweep every pending chunk each minute without ever double-projecting one.

Keys are stable and boring. A chunk job uses str(chunk.id), a conversion uses its content ID, a usage event uses its capture key, and a fan-out uses the job name joined to its sorted scopes.

Failures are held rather than dropped. QueueJob.bind registers with on_failure="hold" and a DatabaseRetryEntrypointExecutor capped at max_attempts, so a job retries five times and then stays in the table with status failed. Queue.requeue_failed puts a bounded window of those back in flight, filtering by entrypoint inside the SQL so one noisy job type cannot crowd out another, and optionally capping how many terminal cycles a row may already have burned. That cap is why ChunkRecoveryJob retries automatically at most chunk_recovery_max_cycles times, which is 3, while an operator running the retry command may pass no cap at all.

cron fires a write arrives
| |
scope_roster (as owner) enqueue chunk / conversion
| |
one job per exact scope set |
+--------------> pgqueuer queue <---+
(partial unique dedupe_key)
|
worker picks the highest-priority ready job
|
handle, decoded to its payload type
| |
success attempts left?
(dedupe key freed) yes --> back to pick
no --> failed, row retained
|
requeue_failed (bounded) --> queue

scope_roster() is the part worth pausing on. It runs under the database owner so row security does not hide other tenants, unions the distinct scopes arrays of document, session_item and artifact, and returns each exact set it finds. A cron tick therefore produces one job per set that actually holds memory, and each of those jobs then runs entirely inside User.system(scopes), so the pass sees exactly the rows a member of that scope set would see.