Agent
The Agent is the server’s asynchronous execution infrastructure. It wraps Celery + Redis behind a domain API (Job, JobRequest, ClientAgent) so the rest of the codebase never touches Celery directly. Long-running work (ICS export/import, …) is pushed as a job from a Flask request, runs in a worker process, and its state and result are read back by polling.
Architecture overview
Four layers, isolated by injection. Only Agent.py imports celery; the business modules see ClientAgent only.
API / Flask Worker / Agent
| |
v v
Interface Api* Interface Agent*
| |
+----------> Module <------+
|
v
ClientAgent (manager)
|
+---------+---------+
v v
Agent JobPersistency
(wrap Celery) (Redis CRUD)
|
v
Redis <---- broker / backend / index / lock
Large blobs do not live in Redis: they are offloaded to the SQL file store
(`sogo6_file_storage`) through a `ClientStorage`, see "Large blobs" below.
Components:
-
Agent (
app/agent/Agent.py) — the only file importingcelery. Wraps the Celery calls (send_task,revoke, signal hooks) and exposes a domain API (create_job,cancel,register_job_handler,register_all_job_handlers,register_lifecycle_hooks,start_worker). It also holds the large-blob store: it builds aClientStoragescoped to the agent at construction andget_large_store()returns it. Worker jobs reach it viaagent.get_large_store(). -
ClientAgent (
app/manager/agent/ClientAgent.py) — the single facade used by Flask. Composes Agent + persistency + canceller + cache; owns enqueue, the concurrency gate andJobStatereads. Receives the store injected at construction (agent.get_large_store()) and re-exposes it via its ownget_large_store(). The worker does NOT build a ClientAgent (jobs useagent.get_large_store()directly) - only Flask does. -
JobPersistency (
app/agent/jobs/JobPersistency.py) — Redis CRUD onJobState, with three secondary indexes (per user / pending / schedule). -
JobCanceller (
app/agent/jobs/JobCanceller.py) — two-phase cancellation (SIGTERM → poll → SIGKILL). -
JobRecovery (
app/agent/jobs/JobRecovery.py) — at boot, replays non-terminal jobs according torequest.resume.
Business modules receive ClientAgent by constructor injection (ModuleCalendar(process_settings, cache=…, agent=…)); they never reach Celery or Agent directly.
End-to-end flow
The Flask process and the Celery worker are two distinct processes. They communicate only through Redis: broker (task queue), backend/state (JobState), indexes, concurrency locks, and large blobs.
+------------------- FLASK PROCESS -------------------+ +---------------- WORKER PROCESS (celery) -----------------+
| | | |
| API (route) -> Interface Api* -> Module | | Agent (wrap celery) -> Interface Agent* -> Module |
| | | | ^ | | |
| v | | | | task_prerun/postrun/retry/... v |
| ClientAgent | | | +-----------> hooks ----------> Job.process |
| | | | | | |
+-----------------------------------------|-----------+ +-------|-----------------------------------------|--------+
| | |
v | v
+========================================== REDIS ===========================================+
| broker queue | jobstate:<id> (+ index user/pending) | agent:concurrency lock |
+============================================================================================+
Large blobs go to the SQL file store, not Redis:
+===================== POSTGRES / MARIADB ======================+
| sogo6_file_storage : sogo:file:<key> (source='agent') |
+==============================================================+
Pushing a job (Flask side)
Module
| request = JobRequestExportIcs(...)
| ClientAgent.enqueue(request, user_uid)
v
ClientAgent
| (1) SET NX EX --------------------------> agent:concurrency:<name>:<uid|_global>
| lock already held? -> RequestException(ERROR_JOB_CONCURRENT_LIMIT) = HTTP 409
| (2) persist JobState(PENDING) -----------> jobstate:<id> (+ index user / pending)
| (3) Agent.create_job -> send_task -------> broker queue
|
| <- job_id
v
HTTP 202 { "job_id": ... } # the API never blocks
Running a job (worker side)
Agent (celery)
| (4) worker pulls task <------------------- broker queue
|
| task_prerun hook
| JobState = STARTED, attempts++ ------> jobstate:<id>
v
Job.process(payload, *, user_uid, job_id)
| InterfaceAgentCalendar -> ModuleCalendar # same module, no HTTP context
| ... heavy work ...
| (5) large output? large_store.save -------> sogo6_file_storage (returns "sogo:file:<key>")
| return { "large_result": ref, ... } # ref is the opaque string; or a small inline dict
v
task_postrun / failure / retry / revoked hook
| (6) JobState = SUCCESS|FAILURE|... + result -> jobstate:<id>
| terminal hook => release concurrency lock (when max_concurrent > 0)
v
(done)
Reading state and result (Flask side, polling)
GET /jobs/<id> -> InterfaceApiJob.get_job
JobStateSerializerDict(state) # status + redacted result
GET /jobs/<id>/result -> InterfaceApiJob.get_result
(7) large_store.load("sogo:file:<key>") <- sogo6_file_storage
HTTP 200 binary (native Content-Type; ?download=true => attachment)
POST /jobs/<id>/cancel -> JobCanceller: SIGTERM -> poll -> SIGKILL; task_revoked => CANCELED
Defining a job
A job is two pieces: a request DTO (the single source of truth for metadata) and a worker that runs it.
@dataclass
class JobRequestExportIcs(JobRequest):
name: ClassVar[str] = "calendar.export.ics" # routing key, unique per job type
soft_timeout_seconds: ClassVar[int] = 300
max_try: ClassVar[int] = 1
resume: ClassVar[bool] = False
max_concurrent: ClassVar[int] = 1
retry_for: ClassVar[tuple] = (Exception,) # exceptions that trigger a retry; narrow to fail fast
calendar_key: str = ""
def payload(self) -> dict: ... # JSON-safe dict sent through the broker
@agent_job
class JobExportIcs(Job):
request_class = JobRequestExportIcs # links worker to its request DTO
def process(self, payload, *, user_uid, job_id) -> dict:
...
Key rules:
-
The request DTO and the worker are two files when the worker depends (transitively) on the module that builds its request — otherwise an import cycle forms at enqueue time.
-
request_classpoints at the DTO, the single source of truth forname/max_try/ etc. The worker never re-declares them. -
No imports inside
process()— all dependencies (includingInterfaceAgentCalendar) are imported at module top. This is safe because the worker is only loaded at boot by auto-discovery, once the app is fully loaded.
Registration is automatic
No central file to edit. At boot, init_jobs filesystem-scans and imports every app/module/<name>/jobs/*.py. Each @agent_job self-registers, then register_all_job_handlers() wires each worker into Celery.
def init_jobs() -> None:
_discover_job_modules() # imports app/module/*/jobs/*.py
agent.register_all_job_handlers()
The jobs/init.py package stays empty: a self-register there would pull the worker’s heavy dependency as soon as the DTO is imported, reclosing the cycle.
Job lifecycle
PENDING --> STARTED --> SUCCESS / FAILURE / CANCELED
| |
| +--> RETRY --> STARTED (same job_id)
|
+--> (worker crash) --> JobRecovery at boot --> PENDING (resume) or FAILURE
Transitions are materialised in Redis by five Celery hooks wired in Agent.register_lifecycle_hooks:
-
task_prerun→ STARTED +attempts++ -
task_postrun→ SUCCESS or FAILURE (skips RETRY) -
task_retry→ RETRY + error -
task_failure→ FAILURE after retries are exhausted -
task_revoked→ CANCELED
The terminal hooks (postrun, failure, revoked) also release the concurrency lock.
At-least-once delivery, so jobs must be idempotent. task_acks_late=True plus the broker visibility timeout mean a job can run more than once (worker lost mid-run, or redelivery after the timeout). process() must therefore be idempotent - re-running it must not double a side effect (no duplicate row, no double send); key the side effect or upsert it rather than blind-append. Retries are governed per-Request by retry_for (default: any exception); narrow it (e.g. (ConnectionError,)) so permanent errors like validation failures fail fast instead of burning max_try attempts with backoff on something that can never succeed.
Concurrency
JobRequest.max_concurrent (default 1) caps the number of same-named jobs in flight (non-terminal) per scope. The scope is implicit:
-
user_uidat enqueue → one lock per user -
no
user_uid→ one global lock (system jobs)
The lock is a Redis SET NX EX on agent:concurrency:<name>:<uid|_global>, acquired by ClientAgent.enqueue before persistence and released by the terminal hooks. TTL = soft_timeout_seconds + 60 (safety net against a worker crash).
-
max_concurrent = 0→ gate disabled (unbounded parallelism, e.g. a notification). -
max_concurrent > 1→ not implemented yet (would need a counter or a TTL sorted-set).
This is not a de-duplication mechanism. It bounds simultaneity, not total throughput: a slot frees as soon as a job finishes, so running N jobs back to back (each finished before the next) lets them all through, by design. A truly authoritative "no new job while one (name, user) exists" gate would scan persisted PENDING/STARTED/RETRY state at enqueue rather than rely on hook timing. Not implemented: no need today.
When the lock is held: RequestException(ERROR_JOB_CONCURRENT_LIMIT) → HTTP 409.
Large blobs (ClientStorage)
A broker must not carry large binaries (message bloat, Redis memory). Blobs too big for a payload/result are offloaded to the large store. This is not an agent-specific mechanism: the store is the same swappable file layer the contact module uses for photos (ClientStorage, app/manager/storage/), the agent is just another owner.
-
ClientStorage(ABC) — the storage contract:save(data, content_type) → str,load(ref) → (bytes, content_type) | None,delete(ref),purge_older_than(max_age_seconds) → int. References are opaque strings of the formsogo:file:<key>(ClientStorage.is_reference); the caller never parses them. -
ClientStorageDatabase(app/manager/storage/) — the DB-backed implementation. It writes blobs tosogo6_file_storage(key, raw bytes, MIME, content hash,source). -
StorageSource— the owner tag stamped on every row (agent,contact, …). Each store instance is scoped to one source, so per-owner enumeration and purge never reach another owner’s blobs (a contact orphan sweep cannot touch an agent result, and vice-versa).
Backend selection by config. The concrete class is chosen at construction through import_and_instantiate_manager(module_path="app.manager.storage", module_and_class_name=f"ClientStorage{SOGO_P_STORAGE_TYPE.capitalize()}", …) - the same pattern as the DB client (Client{SOGO_P_DB_TYPE}). SOGO_P_STORAGE_TYPE defaults to database. Adding a local or webdav backend is a new ClientStorage<Type> file plus a config value; the consumers (Agent, ModuleContact) do not change.
Connection lifetime. ClientStorageDatabase(process_setting, source, db=None) runs in two modes:
-
a live
dbis passed → it reuses that connection and never closes it (the owner does). The contact module uses this, sharing its module connection, so a bulk photo import does not open one connection per blob. -
no
db→ it opens a short-lived connection for each operation and closes it right after. TheAgentimport-time singleton builds its store this way (db=None), so it holds no live connection. That matters under Celery’s prefork model: a connection opened in the master would be inherited - and corrupted - across the worker fork; opening per operation means the connection is always created in the process that uses it.
Access: worker jobs use agent.get_large_store(); Flask code uses ClientAgent.get_large_store() (the store injected into the ClientAgent at wiring) or sogo_agent().get_large_store(). Both return a ClientStorage - callers depend on the abstraction, never the concrete backend.
The store is bidirectional:
-
Output (large result: ICS, PDF…) — the worker
agent.get_large_store().save(content, content_type) → "sogo:file:<key>", and stores that string underlarge_resultplus light metadata. Fetched viaGET /jobs/<id>/result. -
Input (large upload: ICS import, contact import) — the upload is decoded to text at the API read, then the module
save_text()s it before enqueue (the worker no longer has the HTTP request) and puts the reference string in the request (source_ref). The worker reads it back withload_text(), thendelete()s it once consumed (an input blob is single-use). If enqueue fails, the moduledelete()s the ref so no blob dangles.
A small result (JSON, e.g. import counters) is returned directly from process() and stored inline in JobState.result, readable via GET /jobs/<id>.
JobState.result and the broker payload stay JSON (Redis); the large result is just the opaque reference string, so nothing about the storage backend leaks onto the wire.
A reference that no longer loads (blob expired/cleaned, or a stale reference from a previous wire format) is surfaced as a clean 410, never a 500: get_result treats a non-string or missing large_result as "no result".
Agent blobs are purged by the daily admin.cleanup.large_store beat job (see the Periodic jobs section), which deletes rows older than SOGO_P_AGENT_LARGE_STORE_MAX_AGE_SECONDS for the agent source only.
Payload exposure (security)
GET /jobs/<id> returns the job’s payload to its owner. Never put a clear-text secret there (token, password, internal ref) without masking it.
To mask, override Job.public_payload(payload) → dict: it returns the payload unchanged by default; a subclass returns a redacted copy. The worker always receives the full payload — only the API view is filtered. Example: JobImportIcs.public_payload strips source_ref so the uploaded blob’s storage locator (a file path / Redis key) never reaches the client.
The public view of a JobState is built by JobStateSerializerDict (app/agent/jobs/serializer/). It emits only the owner-facing fields — status / payload / result / error — and never the internal ones (job_id, name, user_uid, dates, attempts…): the serializer is the public view, it does not rely on the response schema to drop internals. It runs the payload through public_payload and drops the large_result pointer from the result (an internal locator; the blob is fetched via /jobs/<id>/result).
Serialization layers
Two distinct serialization layers, not to be confused:
-
Internal transport (Redis / broker wire) —
to_dict/from_dict/payloadmethods directly on the object:JobState,JobRequest. The large result needs no such object: it is a plainsogo:file:<key>string, carried as-is inJobState.result. -
API boundary (interface → response) — a dedicated
Serializer:JobStateSerializerDictproduces the public view (payload redaction +large_resultstrip).
The serializer/deserializer contract targets model-to-external-format conversion exposed at the API, not internal transport. The Serializer / Deserializer base classes live in app/utils/serializer/ (shared, domain-agnostic).
Endpoints
User-facing (/jobs/…, blueprint app/api/v1/jobs/):
-
GET /jobs/<id>— theJobStateenvelope, filtered byg.user.uid. -
GET /jobs/<id>/result— the binary blob (native Content-Type).?download=trueaddsContent-Disposition: attachment. -
POST /jobs/<id>/cancel— cancels the job (SIGTERM → grace → SIGKILL viaJobCanceller), idempotent, returns the refreshedJobState(CANCELED).
All of them enforce ownership: 403 if the caller is not the owner, and system jobs (user_uid is None) are never exposed through this user-facing interface.
Crash recovery
At worker boot, JobRecovery.reconcile_orphans() scans non-terminal JobState. For each orphan:
-
If the
JobRequestdeclaresresume=Trueandattempts < max_try→ re-enqueue with the samejob_id. -
Otherwise → explicit FAILURE and release of the concurrency lock.
The scan is guarded by a Redis lock (agent:job_recovery:lock, TTL 60s) so a single worker sweeps at boot.
Periodic jobs (Celery Beat)
A third entrypoint runs the scheduler: poetry run agent-beat (app/agent/run.py:beat → Agent.start_beat). It is distinct from the worker(s): beat schedules (pushes a task to the broker at its time), workers execute.
One beat, many workers. Workers scale horizontally. Beat must run as a single active instance - two beats would each fire the schedule and enqueue duplicates. The entrypoint guards this with a Redis lock (agent:beat:lock, TTL BEAT_LOCK_TTL_SECONDS) refreshed by a heartbeat thread while beat runs; if another beat holds it, the process exits. The lock auto-expires if the holder crashes, so a replacement can take over. (No celery-redbeat: it is not actively maintained; the schedule is defined in code, so plain beat + our own lock is enough.)
A job declares its own schedule. The cron lives on the JobRequest, next to the other execution metadata (max_try, soft_timeout_seconds, resume, max_concurrent): a standard 5-field expression, e.g. "0 2 * * *" = daily at 02:00; a Request without one runs only on demand. At startup Agent.start_beat scans every registered job and builds one beat entry per Request that declares a cron - keyed by the job name, enqueued with the Request’s default payload and a schedule_name tag. Adding or rescheduling a periodic job is a one-line change on the Request, not an edit to a central schedule. The last-run state file is SOGO_P_AGENT_BEAT_SCHEDULE_PATH (default /var/celery/celerybeat-schedule; its directory is provisioned writable by the application user in the agent Dockerfile - start_beat raises a clear error if it is missing/not writable). Current periodic jobs: admin.cleanup.large_store (app/module/admin/jobs/), on the cadence declared by its Request’s cron, which calls agent.get_large_store().purge_older_than(SOGO_P_AGENT_LARGE_STORE_MAX_AGE_SECONDS) to delete agent-source blobs older than that age from sogo6_file_storage; and calendar.sync.external.auto (app/module/calendar/jobs/, every 5 minutes), which sweeps every external ICS calendar and syncs the ones whose sync_interval_minutes has elapsed (see Calendar Module).
Tracking beat jobs. A beat task goes straight to the broker (it does not pass through ClientAgent.enqueue, so no PENDING JobState is persisted). The task_prerun hook therefore creates the JobState when none exists, tagged with the schedule name (schedule_name, carried in the task kwargs) - so periodic runs show up in GET /jobs/<id> and list_by_schedule like any other job.
Testing
-
tests/test_agent/— infrastructure layer (Agent wrapper, Job* classes, lifecycle hooks, JobStateSerializerDict). -
tests/test_manager/test_storage/— the storage layer (ClientStoragepolicy,ClientStorageDatabaseincluding its two connection modes,DbFileStorage). -
tests/test_manager/test_agent/test_ClientAgent.py— the ClientAgent facade plus concurrency tests (lock acquire/release). -
tests/test_interface/test_job/test_InterfaceApiJob.py— the user-facing interface (get_job + get_result + errors).
The pattern: inject MagicMock() for agent, persistency, canceller, cache, and the store. For worker job tests, patch the job module’s agent and drive agent.get_large_store.return_value. For Flask interface tests, patch sogo_agent and drive sogo_agent().get_large_store.return_value.