Jobs Module¶
The Jobs module provides a reusable, DB-backed job queue with leasing, retries, cancellation, and metrics. It is domain-agnostic and used by Chatbooks and Prompt Studio; other subsystems can adopt it incrementally.
Standard Queues¶
- default
- high
- low
Use these names across domains to keep operations consistent.
Connectors Domain¶
The external connectors stack now uses the Jobs module for user-visible and recurring file sync work.
- Domain:
connectors - Queue:
default - Current job types:
importincremental_syncsubscription_renewalrepair_rescan- Trigger paths:
- connector source creation can enqueue
import POST /api/v1/connectors/sources/{source_id}/syncenqueuesincremental_sync- provider webhooks enqueue
incremental_syncafter validation and dedupe - the recurring connectors sync scheduler enqueues
incremental_sync,subscription_renewal, orrepair_rescan
Source-Scoped Fencing¶
Connector sync jobs are not only deduped at Jobs level. They also reserve a source-scoped fence in external_source_sync_state.
active_job_idandactive_job_started_attrack the current source owner- duplicate submissions for the same source return the already-active job when it is still live
- stale fences are cleared before reserving a new job
- workers renew their Jobs lease and update source sync state while running
- file-hosting sync jobs report
processed,skipped,failed, anddegradedcounts in the job result
Audio Studio Domain¶
Audio Studio uses Jobs for user-visible audio asset work.
- Domain:
audio_studio - Queue:
default - Endpoint-enqueued job types:
audio_studio_generateaudio_studio_renderaudio_studio_export- Trigger paths:
POST /api/v1/audio-studio/projects/{project_id}/generationsPOST /api/v1/audio-studio/projects/{project_id}/rendersPOST /api/v1/audio-studio/projects/{project_id}/exports
Audio Studio job payloads are sanitized before persistence. Client requests must not include provider secrets or external URLs; provider base URLs are resolved from environment configuration and checked against AUDIO_STUDIO_EXTERNAL_ENDPOINT_ALLOWLIST. Jobs are idempotent by project, target resource, target revision, job type, and caller idempotency key. Audiobook migration commit currently runs as a server-side transaction through POST /api/v1/audio-studio/migrations/audiobook/commit; the worker has a reserved migration handler for a future async migration path, but the public endpoint does not enqueue that job type today.
Recurring Connector Scheduler¶
tldw_Server_API/app/services/connectors_sync_scheduler.py is an APScheduler bridge that scans sources and enqueues Jobs. It does not perform sync work itself.
CONNECTORS_SYNC_SCHEDULER_ENABLED=truestarts the service at app startupCONNECTORS_SYNC_SCHEDULER_SCAN_SECcontrols scan cadence (default300)CONNECTORS_SYNC_RENEWAL_LOOKAHEAD_SECcontrols how early expiring webhook subscriptions are renewed (default3600)
Admin Quick Reference¶
- Safe reads
GET /api/v1/jobs/stats- aggregated counts by{domain,queue,job_type}(queued vs scheduled vs processing vs quarantined)GET /api/v1/jobs/list- list jobs with filters (domain, queue, status, owner_user_id, job_type, limit, sorting)GET /api/v1/jobs/events- outbox polling (after_id, limit, domain, queue, job_type)GET /api/v1/jobs/events/stream- SSE cursor stream (after_id)GET /api/v1/jobs/queue/status-{ paused, drain }for a queueGET /api/v1/jobs/{job_id}/attachments- list attachments/logs (optionally scoped withdomainfor RBAC/RLS)GET /api/v1/jobs/sla/policies- SLA policies (optionally filtered; respects domain RBAC when enabled)-
GET /api/v1/jobs/archive/meta- archive compression metadata for a job (if archived; optionally scoped withdomainfor RBAC/RLS) -
Admin writes (require
X-Confirm: trueunlessdry_run: true) POST /api/v1/jobs/prune- delete old terminal jobs (supportsdry_runanddetail_top_k)POST /api/v1/jobs/ttl/sweep- cancel/fail queued-by-age and processing-by-runtime- RBAC special-case: with
JOBS_DOMAIN_SCOPED_RBAC+JOBS_RBAC_FORCEand domain provided, returns{affected:0}withoutX-Confirm
- RBAC special-case: with
POST /api/v1/jobs/batch/cancel- cancel queued/processing (scoped)POST /api/v1/jobs/batch/reschedule- delay or set-now queued jobs (scoped)POST /api/v1/jobs/batch/requeue_quarantined- move quarantined back to queued (scoped)POST /api/v1/jobs/{job_id}/attachments- add attachment/log (optionally scoped withdomainfor RBAC/RLS)POST /api/v1/jobs/sla/policy- upsert per-job_type SLA policy (domain-scoped when domain RBAC is enabled)POST /api/v1/jobs/queue/control-{ action: 'pause'|'resume'|'drain' }POST /api/v1/jobs/crypto/rotate- re-encrypt encrypted fields (supportsdry_run, requiresX-Confirmto execute)
Destructive Ops - Always include
X-Confirm: truefor prune, TTL sweep, batch cancel/reschedule/requeue, and crypto rotate unless runningdry_run. - Scope operations withdomain(and optionallyqueue/job_type) to avoid wide-impact actions. - With domain-scoped RBAC enabled and forced (JOBS_DOMAIN_SCOPED_RBAC=true,JOBS_RBAC_FORCE=true), TTL withoutX-Confirmreturns a safe no-op{affected:0}when adomainis provided. - Canonical endpoint for quarantine requeue isPOST /api/v1/jobs/batch/requeue_quarantined; compatibility aliasPOST /api/v1/jobs/batch/requeue-quarantinedremains supported.
Configuration¶
JOBS_DB_URL(optional): PostgreSQL DSN (e.g.,postgresql://user:pass@host:5432/db). If not set, SQLite is used atDatabases/jobs.db.JOBS_LEASE_SECONDS(default 60): Lease duration when acquiring.JOBS_LEASE_RENEW_SECONDS(default 30): Worker renewal cadence.JOBS_LEASE_RENEW_JITTER_SECONDS(default 5): Renewal jitter to avoid herds.JOBS_LEASE_MAX_SECONDS(default 3600): Cap for lease extension.JOBS_ENFORCE_LEASE_ACK(default true): Explicit override that forces lease enforcement on (takes precedence over the disable flag).JOBS_DISABLE_LEASE_ENFORCEMENT(default false): Compatibility switch that allows finalizing withoutworker_id/lease_id. Intended for legacy adapters and targeted tests; avoid enabling in production.JOBS_ALLOWED_QUEUES/JOBS_ALLOWED_QUEUES_<DOMAIN>: Comma-separated allowlists to restrict queue names (in addition to standard queues).JOBS_MAX_JSON_BYTES(default 1048576): Max serialized bytes forpayloadandresult.JOBS_JSON_TRUNCATE(default false): If true, truncate oversizepayload/resultto a small marker instead of rejecting.JOBS_ARCHIVE_BEFORE_DELETE(default false): When true,prune_jobscopies rows tojobs_archivebefore deletion.JOBS_SQLITE_SINGLE_UPDATE_ACQUIRE(default false): Use an optional single-UPDATE acquisition path on SQLite under contention.JOBS_ALLOWED_JOB_TYPES/JOBS_ALLOWED_JOB_TYPES_<DOMAIN>: Comma-separated allowlists of job types. If set,create_jobenforces membership.- Exactly-once finalize (optional):
JOBS_REQUIRE_COMPLETION_TOKEN(default false): When true, workers should passcompletion_token(e.g., thelease_id) tocomplete_job/fail_jobto enforce idempotency.completion_tokenis stored on finalize; repeated finalize with the same token becomes a no-op (returns True). A different token after finalization returns False.- Audit bridge (optional):
JOBS_AUDIT_ENABLED(default false): Enable audit logging for job lifecycle events viaunified_audit_service.JOBS_AUDIT_DB_PATH: Optional override for the audit SQLite DB storing job events (Databases/jobs_audit.dbby default).JOBS_AUDIT_RETENTION_DAYS,JOBS_AUDIT_BUFFER_SIZE,JOBS_AUDIT_FLUSH_SECONDS: Tune retention and buffering for the audit bridge.- Poison quarantine (optional):
JOBS_QUARANTINE_THRESHOLD(default 3): On repeated retryable failures with the sameerror_code, the job transitions toquarantinedinstead of re-queuing.- Integrity sweeper (optional):
JOBS_INTEGRITY_SWEEP_ENABLED(default false),JOBS_INTEGRITY_SWEEP_INTERVAL_SEC(default 60),JOBS_INTEGRITY_SWEEP_FIX(default false).- Periodically flags (and optionally fixes) impossible states like leases on non-processing jobs and expired processing leases.
- Postgres RLS (optional):
JOBS_PG_RLS_ENABLE(default false): Enable row-level security policies that scope access to domains incurrent_setting('app.domain_allowlist').- To scope a connection/session, set the allowlist before issuing queries/updates (example):
SELECT set_config('app.domain_allowlist', 'chatbooks,prompt_studio', true);- The policies will then allow access only to rows where
domainis in that list.
- Metrics/Tracing buckets:
JOBS_DURATION_BUCKETS: CSV of float seconds forduration_secondshistogram buckets.JOBS_QUEUE_LATENCY_BUCKETS: CSV of float seconds forqueue_latency_secondshistogram buckets.- Tracing & Events (optional):
JOBS_TRACING(default false): Log spans for job lifecycle events (create/acquire/complete/fail) with correlation metadata.JOBS_EVENTS_ENABLED(default false): Emit event hooks for job state changes.JOBS_EVENTS_OUTBOX(default false): Persist job events to an append-only outbox tablejob_eventsfor CDC/streaming.JOBS_EVENTS_POLL_INTERVAL(default 1.0): SSE poll interval for/api/v1/jobs/events/stream.JOBS_EVENTS_RATE_LIMIT_HZ(default 0 = unlimited): Soft rate limit for event emission; excess writes are dropped.- Request/Trace correlation:
X-Request-IDis propagated from API → job row (request_id) when passed tocreate_jobby endpoints (audio jobs wired; others can adopt).- A
trace_idis generated per job when not provided; metrics can attach sampled exemplars withJOBS_METRICS_EXEMPLARS=trueandJOBS_METRICS_EXEMPLAR_SAMPLING(default 0.01).
- SLOs (owner/job_type): enable with
JOBS_SLO_ENABLE=true, windowJOBS_SLO_WINDOW_HOURS(default 6). - TTL (optional):
JOBS_TTL_ENFORCE(default false): Enable periodic TTL sweeps in the metrics loop.JOBS_TTL_AGE_SECONDS: Max age for queued jobs (bycreated_at).JOBS_TTL_RUNTIME_SECONDS: Max runtime for processing jobs (byCOALESCE(started_at, acquired_at)).JOBS_TTL_ACTION:cancel(default) orfail.
Queue Policies and Allowlists¶
- Allowed queues
- Global:
JOBS_ALLOWED_QUEUES="q1,q2" - Per-domain:
JOBS_ALLOWED_QUEUES_<DOMAIN>="q3,q4"(e.g.,JOBS_ALLOWED_QUEUES_CHATBOOKS) - Standard queues are always permitted:
default, high, low. - Allowed job types
- Global:
JOBS_ALLOWED_JOB_TYPES="t1,t2" - Per-domain:
JOBS_ALLOWED_JOB_TYPES_<DOMAIN>="t3,t4" - Queue controls (admin endpoints)
POST /api/v1/jobs/queue/controlwith{ domain, queue, action: 'pause'|'resume'|'drain' }pause: block new acquisitions for the queuedrain: allow running jobs to finish, block new acquisitionsresume: clear both pause and drain
GET /api/v1/jobs/queue/status?domain=...&queue=...→{ paused: bool, drain: bool }
Domain-Scoped RBAC (Admin)¶
- Flags:
JOBS_DOMAIN_SCOPED_RBAC(default false): Enforce domain scope for admin endpointsJOBS_REQUIRE_DOMAIN_FILTER(default false): Requiredomainquery/body field when RBAC is enabledJOBS_RBAC_FORCE(default false): Apply checks even in single-user mode (useful for tests)JOBS_DOMAIN_ALLOWLIST_<USER_ID>: Comma-separated domain allowlist for a specific user id- TTL special-case: when RBAC is forced and a
domainis provided,POST /api/v1/jobs/ttl/sweepwithoutX-Confirmreturns{"affected": 0}(no-op) instead of 400. This preserves guardrails while enabling RBAC-only validations. - Prune & retention (optional):
- config.txt [Jobs] keys map to env names without the
JOBS_prefix (env overrides config). JOBS_PRUNE_ENFORCE(default false): Run background prune sweeps (internal scheduler).JOBS_PRUNE_INTERVAL_SEC(default 86400): Interval between prune sweeps (seconds).JOBS_PRUNE_DRY_RUN(default false): Count candidates without deleting.JOBS_PRUNE_DOMAIN,JOBS_PRUNE_QUEUE,JOBS_PRUNE_JOB_TYPE: Optional comma-separated scopes.JOBS_RETENTION_DAYS_TERMINAL: Override all terminal states (completed|failed|cancelled|quarantined).JOBS_RETENTION_DAYS_COMPLETED(default 30)JOBS_RETENTION_DAYS_FAILED(default 60)JOBS_RETENTION_DAYS_CANCELLED(default 60)JOBS_RETENTION_DAYS_QUARANTINED(default 90)JOBS_RETENTION_DAYS_NONTERMINAL(default 0): Days to retain non-terminal (queued|processing). Disabled when 0 (recommended unless intentional).JOBS_ARCHIVE_COMPRESS(default false): When archiving, also write compressed copies ofpayload/resulttojobs_archive.payload_compressed/result_compressed.- SQLite stores Base64-encoded
gzip64:<...>strings. Postgres stores rawBYTEA.
- SQLite stores Base64-encoded
JOBS_ARCHIVE_COMPRESS_DROP_JSON(default false): If true, setpayload/resultto NULL in the archive (use compressed columns only).- Secret hygiene (creation):
JOBS_SECRET_REJECT(default false): Reject job creation if payload appears to contain secrets (keys or regex patterns).JOBS_SECRET_REDACT(default false): Redact detected secrets with***REDACTED***(applies even if not rejecting).JOBS_SECRET_DENY_KEYS: Comma-separated sensitive keys to flag (defaults includeapi_key, authorization, password, token, secret, ...).JOBS_SECRET_PATTERNS: Semicolon-separated regex patterns to detect secrets (default includes OpenAI keys, AWS AKIA, GitHub PAT, JWT, Google API, Slack tokens).- Graceful shutdown:
JOBS_SHUTDOWN_WAIT_FOR_LEASES_SEC(default 0): When >0,/readyflips to not ready and new acquisitions are gated; shutdown waits up to this many seconds for active leases to finish.- Readiness endpoints:
/readyand/health/readyreturn{"status": "not_ready"}during shutdown to drain traffic. - Acquisition gate: on shutdown the server sets a global acquire gate so
acquire_next_jobreturnsNoneuntil restart. - Testing time control:
JOBS_TEST_NOW_EPOCH(seconds since epoch): If set, JobManager’s internal clock uses this instant for lease renewals and TTL comparisons it controls, enabling time-travel tests without sleeps.- Domain-scoped RBAC (optional):
JOBS_DOMAIN_SCOPED_RBAC(default false): Enforce domain scope for admin endpoints.JOBS_REQUIRE_DOMAIN_FILTER(default false): Require thedomainquery/body field for domain-scoped endpoints.JOBS_DOMAIN_ALLOWLIST_<USER_ID>: Comma-separated domain allowlist for a specific user id.
Endpoint: GET /api/v1/config/jobs lists backend, flags, and standard queues.
Core API (Python)¶
from tldw_Server_API.app.core.Jobs.manager import JobManager
jm = JobManager() # auto-selects SQLite or Postgres
# Create a job (queued)
job = jm.create_job(
domain="chatbooks",
queue="default",
job_type="export",
payload={"name": "Weekly", "chatbooks_job_id": "abc123"},
owner_user_id="1",
priority=5,
)
# Acquire next job with a lease
lease_seconds = 60
worker_id = "worker-1"
job = jm.acquire_next_job(domain="chatbooks", queue="default", lease_seconds=lease_seconds, worker_id=worker_id)
lease_id = job["lease_id"]
# Periodically renew (enforcement on)
jm.renew_job_lease(job["id"], seconds=lease_seconds, worker_id=worker_id, lease_id=lease_id)
# Complete with enforcement and exactly-once token (recommended)
jm.complete_job(job["id"], result={"path": "/path/to/file"}, worker_id=worker_id, lease_id=lease_id, completion_token=lease_id)
# Fail (retryable or terminal) with idempotent finalize token
jm.fail_job(job["id"], error="boom", retryable=True, worker_id=worker_id, lease_id=lease_id, completion_token=lease_id)
## Storage Architecture
- `JobManager` keeps its SQL inline and connects directly via sqlite3/psycopg. This retains the tuned acquisition logic, counters, and quarantine flows without an intermediate adapter.
- Schema includes supporting tables alongside `jobs`: `job_events` (outbox), `job_counters`, `job_queue_controls`, `job_sla_policies`, `job_attachments`, and optional `jobs_archive`. Dedicated tests cover each (`tests/Jobs/test_jobs_events_outbox_sqlite.py`, `test_jobs_admin_counters_sqlite.py`, `test_jobs_queue_controls_and_admin_sqlite.py`, `test_jobs_sla_gauges_sqlite.py`, `test_jobs_events_outbox_postgres.py`).
- Schema creation lives in `migrations.py` / `pg_migrations.py`; `_connect()` applies the migrations and pragmas on demand.
- When we add a dedicated storage wrapper it will reside at `app/core/Jobs/storage.py` and wrap the shared database adapters (`app/core/DB_Management/backends/`). Until then, the inline approach is the supported path.
- Both backends are exercised in the Jobs test suite (`tests/Jobs/test_jobs_manager_sqlite.py`, `tests/Jobs/test_jobs_manager_postgres.py`, `tests/Jobs/test_jobs_pg_concurrency_stress.py`), so direct SQL remains coverage-protected.
## Idempotency Scoping
- Idempotency is enforced per (domain, queue, job_type, idempotency_key).
- Submitting a duplicate job with the same idempotency key and the same group returns the original row.
- Reusing the same key in a different queue, job type, or domain creates a distinct job.
- Postgres uses a composite unique index; SQLite mirrors this with a partial unique index (idempotency_key IS NOT NULL).
## Status Guardrails
- Legal transitions are enforced at the DB boundary:
- `queued → processing → {completed, failed, cancelled}`
- `processing → queued` only on retry (fail_job with retryable=True)
- `complete_job` and terminal `fail_job` affect rows only when `status='processing'`.
- Completing/failing a non-processing job is a no-op (returns False, no change).
Operational Guidance¶
- Leases and Reclaim:
- Expired
processingleases are reclaimed on the next acquire. -
Lease enforcement is enabled by default. Only use
JOBS_DISABLE_LEASE_ENFORCEMENT=1for legacy adapters or targeted tests that cannot passworker_id/lease_id. -
Priorities and Fairness:
- Lower numeric
prioritymeans higher priority (default is 5). -
Acquisition order is explicit and stable:
priority ASC, thenavailable_at/created_atASC, thenidASC. -
Ready vs Scheduled semantics:
- Ready =
status='queued'andavailable_at IS NULL OR available_at <= now() - Scheduled =
status='queued'andavailable_at > now() - Metrics and stats distinguish
queued(ready) fromscheduled; backlog = ready + scheduled. -
Admin reschedule can move ready → scheduled (
/api/v1/jobs/batch/reschedule), or scheduled → ready (set_now=true). -
Pruning:
- Manager exposes
prune_jobs(statuses, older_than_days, domain=None, queue=None, job_type=None, dry_run=False)to delete oldcompleted/failed/cancelledjobs based oncompleted_at(fallback tocreated_at). - Use
domain/queue/job_typeto scope deletion; passdry_run=Trueto preview the count only (no deletes). - Admin endpoint (admin-only):
POST /api/v1/jobs/prune- Request (JSON):
statuses: list of statuses, e.g.["completed","failed","cancelled"]older_than_days: integer days threshold (min 1)- Optional filters:
domain,queue,job_type - Optional
dry_run: boolean (default false) - Optional
detail_top_k: integer (0..100). Whendry_run=true, compute top-K groups by count (for preview). - Response:
{ "deleted": <int> }- for dry runs this is the would-delete count.
- Examples:
- Preview prune for a single queue:
curl -X POST "$BASE/api/v1/jobs/prune" \ -H "X-API-KEY: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "statuses": ["completed","failed"], "older_than_days": 14, "domain": "chatbooks", "queue": "default", "job_type": "export", "dry_run": true }' - Execute the prune (remove
dry_runor set false):# Same body with "dry_run": false
- Preview prune for a single queue:
-
WebUI:
- Admin → Jobs includes a Prune panel with a “Dry Run (count only)” toggle and a “Saved Filters” badge reflecting current Domain/Queue/Job Type filters. Use Reset Filters to clear.
-
Metrics:
- Gauges:
jobs.queued{...}(ready only),jobs.scheduled{...},jobs.processing{...},jobs.backlog{...}(ready + scheduled). - Histograms:
jobs.duration_seconds{...},jobs.queue_latency_seconds{...},jobs.retry_after_seconds{...}. - Counters:
jobs.created_total{...},jobs.completed_total{...},jobs.cancelled_total{...},jobs.retries_total{...},jobs.failures_total{...,reason},jobs.failures_by_code_total{...,error_code}. - Lease tuning:
jobs.time_to_expiry_seconds{...}histogram reflects remaining time on active leases. - Per-owner SLO gauges: queue latency and duration P50/P90/P99 per
{domain,queue,job_type,owner_user_id}:jobs.queue_latency_p50_seconds/_p90_/_p99_jobs.duration_p50_seconds/_p90_/_p99_
-
Structured failure timeline stored on job rows:
failure_timelineJSON (last ~10 entries) with{ts, error_code, retry_backoff}for WebUI analytics. -
Counters vs Reconcile:
JOBS_COUNTERS_ENABLED(default false): enable per-group counters injob_countersto avoid frequent COUNT(*) scans- Inline transitions update counters when fully scoped (e.g., create, acquire, finalize, TTL, batch ops)
- Gauges use counters when available; otherwise they compute fresh counts
JOBS_GAUGES_DEBOUNCE_MS(default 0): debounce gauge updates in high-churn paths- Background reconcile (optional):
JOBS_METRICS_GAUGES_ENABLED=trueemits SLO gaugesJOBS_METRICS_RECONCILE_ENABLE=trueenables periodic reconcile of counters/gaugesJOBS_METRICS_RECONCILE_GROUPS_PER_TICK(default 100) caps groups per loop to avoid heavy scans
Docker Compose (Postgres)¶
- The repository ships a
docker-compose.ymlwith apostgresservice. To run Jobs on Postgres when using Compose: - Set the DSN using the
postgresservice hostname inside the Compose network:export JOBS_DB_URL=postgresql://tldw_user:TestPassword123!@postgres:5432/tldw_users
- Start services:
docker compose up --build
- From your host, you can also connect via the published port:
export JOBS_DB_URL=postgresql://tldw_user:TestPassword123!@localhost:5432/tldw_users
- The Jobs manager will auto-provision the schema on first use.
Running Postgres Jobs tests¶
- Ensure a Postgres instance is available (e.g., via Compose above) and set one of:
export JOBS_DB_URL=postgresql://tldw_user:TestPassword123!@localhost:5432/tldw_users- or
export POSTGRES_TEST_DSN=postgresql://... - Run only PG-marked Jobs tests:
-
python -m pytest -m "pg_jobs" -v tldw_Server_API/tests/Jobs -
TTL Sweep (optional):
- Admin endpoint (admin-only):
POST /api/v1/jobs/ttl/sweep- Request:
{ age_seconds?: int, runtime_seconds?: int, action: 'cancel'|'fail', domain?: string, queue?: string, job_type?: string } - Action applies to queued jobs older than
age_secondsand processing jobs running longer thanruntime_seconds. - Response:
{ "affected": <int> }.
- Request:
- Metrics integrate with the centralized metrics manager; no external setup required.
Notes¶
- SQLite is suitable for single-instance or dev; Postgres is recommended for multi-worker deployments.
- Leader election for maintenance (Postgres): TTL and prune sweeps acquire per-
{domain,queue}advisory locks viapg_try_advisory_lockso only one instance performs the sweep for that shard. - Avoid long leases; prefer short leases with renewals.
Developer Ergonomics¶
- Worker SDK
- A lightweight helper lives at
tldw_Server_API/app/core/Jobs/worker_sdk.py. - Provides auto-renew with jitter, optional progress heartbeats, and simple cancellation checks.
- WorkerConfig keys:
domain,queue,worker_idlease_seconds(default 30),renew_threshold_seconds(default 10),renew_jitter_seconds(default 5)backoff_base_seconds(default 2),backoff_max_seconds(default 30)retry_on_exception(default true),retry_backoff_seconds(default 10)
-
Example usage:
from tldw_Server_API.app.core.Jobs.manager import JobManager from tldw_Server_API.app.core.Jobs.worker_sdk import WorkerSDK, WorkerConfig import asyncio async def handler(job): # do work... return {"ok": True} async def main(): jm = JobManager() sdk = WorkerSDK(jm, WorkerConfig(domain="prompt_studio", queue="default", worker_id="w1")) await sdk.run(handler=handler) asyncio.run(main()) -
Local CLI (prints cURL)
Helper_Scripts/tldw_jobs.pygenerates auth-aware cURL for stats, list, prune, TTL, and archive meta.-
Examples:
python Helper_Scripts/tldw_jobs.py stats --domain prompt_studiopython Helper_Scripts/tldw_jobs.py prune --domain prompt_studio --queue default --older_than_days 30 --dry_runpython Helper_Scripts/tldw_jobs.py archive-meta --job_id 123
-
Archive compression metadata
- Admin endpoint:
GET /api/v1/jobs/archive/meta?job_id=<id> - Returns booleans for JSON presence and compressed payload/result presence in
jobs_archive. - Workers should check
cancel_requested_atand verify lease validity between chunks before writing final results.
API: Stats and Listing¶
GET /api/v1/jobs/stats(admin-only): returns aggregated counts per(domain, queue, job_type)with fields:queued(ready only),scheduled(available in the future),processing,quarantined-
Filterable by
domain,queue,job_type -
GET /api/v1/jobs/list(admin-only): paged job listing withdomain,queue,status,owner_user_id,job_type,limit - Sorting:
sort_byin {created_at,priority,status} andsort_orderin {asc,desc}. - Example:
GET /api/v1/jobs/list?domain=chatbooks&sort_by=priority&sort_order=asc
Admin Endpoint Safety¶
- Destructive admin endpoints require an explicit confirmation header to avoid accidental deletion:
POST /api/v1/jobs/prune: SetX-Confirm: trueunlessdry_run: true.POST /api/v1/jobs/ttl/sweep: SetX-Confirm: true.POST /api/v1/jobs/batch/canceland/jobs/batch/reschedule: SetX-Confirm: trueunlessdry_run: true.- Without the header these endpoints return HTTP 400.
Schema Notes¶
- New installs enforce:
- CHECK status ∈ {queued, processing, completed, failed, cancelled, quarantined}
- CHECK priority in [1..10], max_retries in [0..100]
- progress_percent [0..100] and progress_message fields (workers can update via
renew_job_lease) -
Postgres adds partial indexes accelerating counts and acquisitions
-
Forward migrations (Postgres):
- On startup,
ensure_jobs_tables_pg()creates tables and performs safe, idempotent forward migrations for missing columns used by the Jobs module (e.g.,completion_token,failure_streak_code/count,quarantined_at,progress_percent/message,error_code/class/stack). - It also creates hot-path indexes concurrently, including the acquisition-order index:
(priority, COALESCE(available_at, created_at), id) WHERE status='queued'. - This behavior is intended for dev/test and simple upgrades. For production change control, consider gating with an environment flag and running DDL as part of a managed migration process.
Integrity Sweep (optional)¶
- Admin endpoint (admin-only):
POST /api/v1/jobs/integrity/sweep - Request:
{ fix: boolean, domain?: string, queue?: string, job_type?: string }
Events (Outbox)¶
- Poll events:
GET /api/v1/jobs/events?after_id=<cursor>&limit=<N>&domain=&queue=&job_type=(admin-only)- SSE stream:
GET /api/v1/jobs/events/stream?after_id=<cursor>(admin-only)- Emits text/event-stream with incremental IDs; clients can resume by passing
after_id. - Requires
JOBS_EVENTS_OUTBOX=trueto persist events; otherwise events are process-local only. - Admin endpoints set per-request Postgres RLS context automatically when enabled.
- Response:
{ non_processing_with_lease: int, processing_expired: int, fixed: int } - When
fix=true, clears stale lease fields on non-processing rows, and re-queues expired processing rows.
Quarantine Triage (Admin Runbook)¶
- When jobs repeatedly fail with the same
error_code, they transition toquarantinedafterJOBS_QUARANTINE_THRESHOLDretryable failures. Quarantine prevents automatic re-queueing to stop hot loops. - Triage steps:
- Inspect top failure codes and affected domains/queues in logs and metrics (
jobs.failures_by_code_total). - Use
GET /api/v1/jobs/statsto seequarantinedcounts by domain/queue/job_type. - Use
POST /api/v1/jobs/batch/requeue_quarantinedwithdry_run=trueto preview impact. Scope bydomain, optionallyqueueandjob_type. - Requeue with confirmation header when root cause is mitigated (e.g., fixed inputs, raised limits):
- Header:
X-Confirm: true - Example cURL (dry run):
curl -X POST "$BASE/api/v1/jobs/batch/requeue_quarantined" \ -H "X-API-KEY: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "domain": "chatbooks", "queue": "default", "job_type": "export", "dry_run": true }' - Example cURL (real run with confirm):
curl -X POST "$BASE/api/v1/jobs/batch/requeue_quarantined" \ -H "X-API-KEY: $API_KEY" \ -H "X-Confirm: true" \ -H "Content-Type: application/json" \ -d '{ "domain": "chatbooks", "queue": "default", "job_type": "export", "dry_run": false }'
- Header:
- Consider raising or lowering
JOBS_QUARANTINE_THRESHOLDper environment. Keep it conservative in production to guard against hot loops. - If a subset remains problematic, continue quarantine and investigate upstream data, credentials, or provider limits.
- Domain quotas (optional):
JOBS_QUOTA_MAX_QUEUED/JOBS_QUOTA_MAX_QUEUED_<DOMAIN>/JOBS_QUOTA_MAX_QUEUED_USER_<USER_ID>/JOBS_QUOTA_MAX_QUEUED_<DOMAIN>_USER_<USER_ID>JOBS_QUOTA_SUBMITS_PER_MIN/..._<DOMAIN>/..._USER_<USER_ID>/..._<DOMAIN>_USER_<USER_ID>JOBS_QUOTA_MAX_INFLIGHT/..._<DOMAIN>/..._USER_<USER_ID>/..._<DOMAIN>_USER_<USER_ID>- Precedence: domain+user > user > domain > global.
- Notes: If
owner_user_idis not supplied, inflight checks are skipped; configure workers to passowner_user_idwhen needed. Inflight counts onlyprocessingjobs with a live lease (leased_untilin the future); expired leases do not count.
Encryption & Rotation¶
- Enabling encryption
- Set
WORKFLOWS_ARTIFACT_ENC_KEYto a strict base64-encoded 32-byte AES-256 key - Enable globally with
JOBS_ENCRYPT=trueor per-domain withJOBS_ENCRYPT_<DOMAIN>=true - When enabled,
payload/resultare stored as envelopes:{ "_encrypted": {"_enc":"aesgcm:v1", ... } } - Reading with dual keys (rotation window)
- Set
JOBS_CRYPTO_SECONDARY_KEYto the previous key to allow reads during rotation - Rotating stored rows (admin)
POST /api/v1/jobs/crypto/rotate(admin-only)- Body:
{ old_key_b64, new_key_b64, domain?, queue?, job_type?, fields?: ["payload","result"], limit?: 1000, dry_run?: true } - Dry run counts candidates; execution requires
X-Confirm: trueheader
- Body:
- After rotation
- Update
WORKFLOWS_ARTIFACT_ENC_KEYto the new key and removeJOBS_CRYPTO_SECONDARY_KEY
Deterministic Clock (Testing)¶
JOBS_TEST_NOW_EPOCHcan freeze time (UTC epoch seconds) for deterministic tests- Internals (Postgres/SQLite) plumb this clock through acquisition, renew, TTL, and batch operations to keep behavior reproducible
Signed Webhooks (optional)¶
- Enable a background worker that posts HMAC-signed webhooks for
job.completedandjob.failedusing the job events outbox. - Flags:
JOBS_WEBHOOKS_ENABLED=trueJOBS_WEBHOOKS_URL=https://example.com/jobs/webhookJOBS_WEBHOOKS_SECRET_KEYS=primary,oldkey(rotating; first key used to sign)JOBS_WEBHOOKS_INTERVAL_SEC(default 1.0),JOBS_WEBHOOKS_TIMEOUT_SEC(default 5)JOBS_WEBHOOKS_CURSOR_PATH(optional): path to persist the last delivered outbox id across restarts- Headers sent:
X-Jobs-Event:job.completed|job.failedX-Jobs-Event-Id: outbox id (monotonic cursor)X-Jobs-Timestamp: epoch secondsX-Jobs-Signature:v1=<hex>wherehex = HMAC_SHA256(secret, f"{ts}.{body}")- Body: JSON
{ event, attrs, job: {id,domain,queue,job_type}, created_at } - Verification example (Python):
import hmac, hashlib, time, json def verify(ts: str, body: bytes, sig_header: str, secrets: list[str], max_skew: int = 300) -> bool: if abs(int(time.time()) - int(ts)) > max_skew: return False try: scheme, value = sig_header.split('=', 1) except Exception: return False if scheme != 'v1': return False msg = f"{ts}.".encode() + body for sk in secrets: calc = hmac.new(sk.encode(), msg, hashlib.sha256).hexdigest() if hmac.compare_digest(calc, value): return True return False