The @track decorator¶
Apply celery_manager.decorator.track() above @app.task to opt a task
into three things:
- Per-occurrence schedule control — if someone pauses/cancels a specific future run from the dashboard, this decorator is what makes the task actually skip its body at that run instead of executing.
- Log capture —
loggingoutput produced during the task is captured and shown on the task's detail page, not just the traceback on failure. See What "log capture" actually captures —print()is included in a real worker, but for a reason that is worth knowing. - Visibility while the task is still running — the decorator records a
startedrow before it calls your function, so an in-flight task is visible even with no events-listener process running, andreport_progress()has a row to write to. See Thestartedrow.
import celery_manager
@app.task(bind=True)
@celery_manager.track(schedule_name="send-report-every-2-hours")
def send_report(self):
...
(from celery_manager.decorator import track also works and is equivalent —
celery_manager.track is just the documented public alias.)
bind=True is not required. It is used throughout these docs because a
bound task is what lets you call self.retry(), and because it is the more
common shape for the kind of task worth tracking — but @track works on an
ordinary unbound task too, and records it under its real name and task id:
Under the hood the decorator identifies the bound Task by type and falls
back to Celery's current_task when there isn't one, rather than assuming the
first positional argument is the task. That distinction matters: assuming it
meant an unbound task's first argument was silently treated as the task
object, which is how this used to fail.
This decorator only does its job in a process that has been wired with
celery_manager.setup(config) — see Wiring
below, which is the step most easily missed.
schedule_name should match the name you use for this schedule in the
dashboard's periodic schedules (i.e. the key under beat_schedule in your
Celery app's config). If omitted, it defaults to the task's registered
name — fine as long as you only have one schedule per task.
Tasks without @track are still visible in the dashboard (the events
listener picks up every task automatically) but don't support
per-occurrence pause/cancel or full log capture — only the traceback
Celery already provides on failure. (report_progress() is
the exception: it works on an undecorated task too, via
celery.current_task.)
Wiring — the worker process must call celery_manager.setup()¶
@track needs a session factory before it can do anything, and that wiring
is per process. Decorated tasks execute in your Celery worker
process, so the worker is the process that has to be wired — not (only) the
one that builds the dashboard app.
Call celery_manager.setup(config) at import time in the module that
defines your tasks, so it runs before any task does:
import celery_manager
from celery_manager.config import CeleryManagerConfig
from myproject.celery import app
celery_manager.setup(CeleryManagerConfig(
celery_app=app,
db_url="sqlite:///celery_manager.db", # the same DB the dashboard reads
secret_key="a-real-secret-in-production",
))
@app.task(bind=True)
@celery_manager.track(schedule_name="send-report-every-2-hours")
def send_report(self):
...
setup() does the whole per-process wiring (create the engine, ensure the
tables exist, hand the decorator a session factory) and is idempotent, so
it's safe if more than one module calls it. create_app() calls the same
function internally, which is why the dashboard process doesn't need a
separate setup() call — but the dashboard process is not where your
tasks run, so wiring it does nothing for the worker.
If a decorated task runs in a process that never called setup(), @track
degrades gracefully rather than raising: the task body still runs normally,
but with no log capture, no schedule-override check, and no task_runs
row of its own — meaning per-occurrence pause/cancel silently does nothing
and report_progress() has nowhere to write. Because that failure is
invisible from the outside, the decorator logs a one-time WARNING
(celery_manager.decorator) the first time a tracked task runs unconfigured.
If you're not seeing pause/skip or captured logs, check the worker's log for
that warning first.
The started row written before your task body¶
Before calling your function, @track writes (or upserts) a task_runs row
with status="started", the task name, its args/kwargs, the schedule name,
and started_at. The terminal succeeded/failed row is written afterwards
as before.
This exists because the decorator used to write only when a task finished.
A long-running task was therefore invisible in the dashboard unless the
separate events-listener process happened to be running, and
report_progress() had no row to write to until the run was
already over — which defeats live progress entirely.
What makes it safe:
- Against itself. The
startedwrite and the terminal write happen in the same thread, in sequence, around the body. The finalsucceeded/failedalways lands after thestartedand therefore wins. There is no "late started" window inside the decorator. - Against the events listener. The listener treats
startedas an ordinary lifecycle status it may overwrite — onlyskippedis in its decorator-owned guard — and every event it could apply here (task-started,task-succeeded,task-failed) is emitted by the worker at or after the moment this row is written.startedis deliberately not added to that guard: doing so would stop the listener applying the real terminal status to a run whose decorator write failed, which is strictly worse.
What a retry attempt clears
Celery reuses the task id across retry attempts, so there is one row for
all of them. Attempt N+1's started write rewrites the status (correct
— the row should show the attempt now in flight) and additionally
erases what the previous attempt left behind: exception,
traceback, finished_at, runtime_seconds, and all four progress_*
columns. Without that, a task that failed once and then succeeded showed
as succeeded with a full traceback and a stale progress bar attached,
which is a worse lie than showing nothing. log_output is not cleared
here — it is rewritten wholesale by whichever branch ends the attempt.
Cost: one extra INSERT/UPDATE and commit per tracked run. That is accepted deliberately — it is one write against a run that is about to do real work, and it buys visibility of every in-flight task without requiring the events listener at all. Like every other write in this decorator, a failure is logged and dropped: it can never change your task's outcome.
self.retry() is recorded as retried, not failed¶
self.retry() raises celery.exceptions.Retry, which is an ordinary
Exception subclass — so it used to land in the decorator's failure branch
and record a run Celery considers RETRY as failed, with Celery's
"Retry in 30s: RuntimeError('boom')" message sitting in the exception
column and a finish time on a run that has not finished. It is now recorded
as status="retried" with finished_at/runtime_seconds left unset,
matching the retried status the events listener writes from
task-retried; the Retry is re-raised unchanged, so Celery's own retry
machinery is untouched.
Only Retry is special-cased. Ignore and Reject share its base class but
mean "this message is being dropped", which has no status in
celery-manager's vocabulary — they still fall through to failed, which at
least surfaces them rather than silently reclassifying them.
Args and kwargs are stored best-effort¶
The decorator serializes a run's args/kwargs to JSON before calling your
task body, which makes the serializer a hazard: json.dumps is stricter than
Celery is. Celery's own JSON serializer round-trips datetime, date,
UUID and Decimal, so send_task(kwargs={"since": datetime(...)}) is
ordinary supported usage that arrives in the worker as a real datetime —
and stdlib json.dumps raises TypeError on it. That exception used to
propagate out of the wrapper, marking the task FAILURE without ever running
its body: celery-manager deciding a task's outcome, which is exactly what it
must never do.
Values stdlib JSON cannot express are now stored as their repr inside
otherwise-valid JSON, so the shape survives (a list stays a list) and the
value stays searchable by q=. The trade-off: those leaves no longer
round-trip, so retrying such a run
re-dispatches the repr string rather than the original object. Pass explicit
args/kwargs to control.retry_task() when that matters.
What "log capture" actually captures¶
capture_logs() attaches a logging.StreamHandler to the root logger
for the duration of the task body, and nothing else. It does not redirect
sys.stdout or sys.stderr. So what lands in log_output is: anything
logged through the logging module by your task or by any library it calls,
as long as that logger propagates to root.
print() is captured too in a real Celery worker — but indirectly, and it is
worth knowing why, because it stops being true in some setups. Celery's
worker sets worker_redirect_stdouts = True by default, which replaces
sys.stdout/sys.stderr with a proxy that writes to the celery.redirected
logger at WARNING. That propagates to root, so our handler sees it:
Two consequences:
print()is not captured outside a worker. Undertask_always_eager = True(a test, a shell,.apply()), nobody installs that redirect, soprint()goes to the real stdout andlog_outputholds only theloggingcalls.print()is not captured if you turn the redirect off. Settingworker_redirect_stdouts = False, or raisingworker_redirect_stdouts_levelabove the level your prints land at, takes it away — and celery-manager has no way to notice.
If you want output that is reliably captured in every one of those setups,
log it: logger.info(...) rather than print(...).
Empty log_output? Check your log level.
capture_logs() adds a handler but never sets a level, so the root
logger's effective level still decides whether a record is created at
all. Python's default is WARNING, which means a task doing
logger.info("...") produces an empty log_output unless something
raised the level — and a Celery worker only does that if you started it
with a level that allows it (celery -A app worker -l info). Started at
the default WARNING, logger.info() from inside your task is dropped
before celery-manager ever sees it.
This bites in eager mode too: running the bundled demo's tasks through
.apply() in a plain shell records the run correctly but leaves
log_output empty, because nothing configured logging in that process.
How the skip/cancel check works¶
On every call, the wrapper looks for an unconsumed ScheduleOverride row
for effective_schedule_name (either the given schedule_name or the
task's registered name) whose target_time is within
eta_tolerance_seconds (default 60, configurable via
CeleryManagerConfig.eta_tolerance_seconds) of now.
This tolerance-window match — not exact timestamp equality — is
deliberate: a periodic task's actual fire time and the target_time a user
paused from the dashboard's occurrence list are computed independently and
will rarely line up to the microsecond.
- If a matching override exists and its
actionisskiporcancel, the decorator marks itconsumed, records aTaskRunwithstatus="skipped", and returnsNonewithout calling the wrapped function — the task body never runs for that occurrence. - Otherwise the task runs normally. Its log output is captured (via
celery_manager.logcapture.capture_logs, which attaches a handler to the root logger for the duration of the call) and, together with the outcome (succeeded/failed), exception, traceback, and runtime, persisted as aTaskRunrow.
One implementation detail worth knowing if you inspect the database
directly: SQLite drops timezone info on DateTime(timezone=True) columns
on read-back, even though every value in this codebase is always written
as UTC. The matching logic treats any timezone-naive target_time read
from the database as UTC rather than as local time — this only matters if
you're querying schedule_overrides yourself; the decorator and API
handle it transparently.
reschedule exists as a value the ScheduleOverride.action column permits
(alongside the unused reschedule_to column), but the decorator's skip check
only special-cases skip and cancel — a reschedule override would not
change what the decorator does when the task's original occurrence comes
around. Because of that, the pause endpoint now rejects action="reschedule"
with a 422 rather than storing an override that does nothing while
displaying as paused.