Async Tasks Expert
Expert guidance for the Frappe Tweaks Async Task system — a managed, observable alternative to raw frappe.enqueue.
When to Use Async Tasks vs frappe.enqueue
| Concern |
frappe.enqueue |
Async Tasks |
| Observability |
No built-in log |
Full log: status, timing, errors, memory |
| Concurrency control |
None |
Per-method concurrency_limit |
| Priority ordering |
None |
Per-method priority + per-task at_front |
| Cancellation |
Cannot cancel |
Cancel from UI or code |
| Server Script support |
No |
Yes (callwhitelistedfunction=True) |
| Deduplication |
Optional (job_id) |
Via dispatcher deduplication |
| Auto-retry on failure |
No |
Yes (maxretries + retrydelay) |
Use Async Tasks when any of the following apply:
- You need to see task status, errors, or execution time in the UI
- Multiple agents/jobs can enqueue the same method and you need a cap on concurrency
- You want tasks to queue up and respect a priority order rather than flood workers
- The method is a whitelisted function or Server Script
- You need to be able to cancel tasks programmatically
- You want failed tasks to be automatically retried after a configurable delay
Stick with frappe.enqueue when:
- The job is fire-and-forget with no UI visibility required
- You are inside a framework hook that already handles retries/logging (e.g., Sync Jobs)
Public API
from tweaks.tweaks.doctype.async_task_log.async_task_log import (
enqueue_async_task,
enqueue_safe_async_task,
bulk_enqueue_async_task,
bulk_enqueue_safe_async_task,
)
from tweaks.tweaks.doctype.async_task_log.async_task_log_dispatch import (
toggle_dispatcher,
can_dispatch_now,
)
enqueueasynctask
task = enqueue_async_task(
method=None, # dotted path str OR callable; optional when document_* fields are set
queue="default", # "default" | "short" | "long"
timeout=300, # seconds; default 300
job_name=None, # human-readable label (title); defaults to method when omitted
# --- document action shorthand (alternative to method) ---
document_type=None, # DocType name ┐ all three must be provided
document_name=None, # document name ┤ together; method is then auto-derived
document_action=None, # method to call on the document (e.g. "submit") ┘
# --- retry options ---
max_retries=0, # max automatic retry attempts on failure; 0 = no retries
retry_delay=None, # seconds to wait after failure before retrying; None = retry immediately
# --- other options ---
at_front=False, # jump ahead of other Pending tasks for this method
call_whitelisted_function=False,# execute via call_whitelisted_function (Server Scripts)
batch_id=None, # optional batch group label; tasks sharing a batch_id are ordered together
batch_order=None, # position within the batch; lower values dispatch first
arguments=None, # explicit dict of method kwargs; overrides **kwargs on key collision
**kwargs, # forwarded to method / document action as keyword arguments
)
# task.name → Async Task Log document name
Rules:
- Pass either
method or all three of documenttype + documentname + document_action. Passing neither raises ValueError.
- When the document fields are used,
method is automatically derived as the doctype controller dotted path + ".{action}" (e.g. "erpnext.accounts.doctype.salesinvoice.salesinvoice.submit").
jobname is an optional human-readable label stored as the task title. When omitted, it is set to method automatically in beforeinsert.
- Inner function resolution is applied at execution time: if the document controller defines
submit, it is called instead of submit (mirroring Document.queueaction).
- Use
arguments={"queue": "short"} (not queue= in kwargs) whenever a method argument name collides with an enqueueasynctask API parameter — arguments takes priority over kwargs and its keys are never intercepted by the function signature.
max_retries=0 (the default) disables automatic retries. Set to a positive integer to enable them.
retry_delay is the number of seconds to wait (measured from the task's modified timestamp on failure) before the next retry attempt. None or 0 means retry on the next dispatch cycle.
enqueuesafeasync_task
Shorthand for enqueueasynctask(..., callwhitelistedfunction=True). Use when calling whitelisted functions or Server Scripts by name. Accepts the same jobname, maxretries and retry_delay parameters.
task = enqueue_safe_async_task(
"myapp.api.sync_customer",
queue="short",
customer_id="CUST-0001",
)
bulkenqueueasync_task
Create multiple Async Task Log documents in one call and dispatch them together as a single ordered batch.
tasks = bulk_enqueue_async_task(
tasks=[ # list of per-task dicts (same keys as enqueue_async_task)
{"method": "myapp.utils.process_invoice", "invoice_name": "INV-001"},
{"method": "myapp.utils.process_invoice", "invoice_name": "INV-002", "at_front": True},
],
batch_id="my-import-run-abc", # optional; auto-generated uuid4 when omitted
queue="short", # kwargs here overwrite matching keys in every task dict
)
How it works:
- If
batch_id is not given, a random UUID is assigned so all tasks share the same batch.
- Each task's
batch_order is set sequentially (0, 1, 2 …) in insertion order.
- The dispatcher is internally suspended while inserting to prevent partial dispatches, then resumed atomically once all documents are committed.
- A single
dispatchasynctasks job is enqueued at the end, respecting concurrency limits for all inserted tasks.
Key behaviour:
- Extra
kwargs are merged into every** task dict (useful for shared fields like queue or timeout).
- Individual task dicts can still override merged values before calling
enqueueasynctask.
- Returns the
batchid string (auto-generated if not provided); use it to query Async Task Log by batchid to track completion.
bulkenqueuesafeasynctask
Shorthand for bulkenqueueasynctask(..., callwhitelisted_function=True). Use when each method is a whitelisted function or Server Script name.
bulk_enqueue_safe_async_task(
tasks=[
{"method": "myapp.api.sync_customer", "customer_id": "CUST-0001"},
{"method": "myapp.api.sync_customer", "customer_id": "CUST-0002"},
],
queue="short",
)
Passing a Callable
from myapp.utils import process_invoice
task = enqueue_async_task(process_invoice, invoice_name="INV-001")
# Internally resolves to "myapp.utils.process_invoice"
Document Action Shorthand
Use documenttype + documentname + documentaction instead of method when you want to call a method on a specific document in the background. This is the async-task equivalent of doc.queueaction().
# Submit a Sales Invoice in the background
task = enqueue_async_task(
document_type="Sales Invoice",
document_name="SINV-0042",
document_action="submit",
queue="long",
)
# Call a custom document method with kwargs
task = enqueue_async_task(
document_type="My Doctype",
document_name="MY-001",
document_action="my_custom_method",
some_arg="value",
)
Execution behaviour:
frappe.getdoc(documenttype, document_name) is called inside the worker.
doc.unlock() is called to release any file lock left from the caller.
- Inner function resolution: if
doc.submit exists and documentaction="submit", _submit is called instead.
getattr(doc, action)(**kwargs) is invoked with any extra kwargs.
Async Task Type concurrency is keyed on the derived method string, so you can set concurrencylimit on "erpnext.accounts.doctype.salesinvoice.sales_invoice.submit" as usual.
Async Task Type (optional configuration)
Create an Async Task Type document with method matching the dotted path to configure:
| Field |
Default |
Effect |
priority |
0 |
Higher = dispatched first among Pending tasks |
concurrency_limit |
0 (unlimited) |
Max simultaneous Queued/Started tasks for this method |
is_standard |
False |
Protect from accidental deletion in production |
Creating via code (fixtures/patches):
frappe.get_doc({
"doctype": "Async Task Type",
"method": "myapp.utils.heavy_import",
"priority": 10,
"concurrency_limit": 2,
"is_standard": 1,
}).insert(ignore_if_duplicate=True)
Status Lifecycle
Pending → Queued → Started → Finished
↘ Failed ──(auto-retry)──→ Pending
↘ Canceled (from Pending, Queued, or Started)
- Pending: Created, waiting for dispatcher to promote it.
- Queued: Pushed to RQ, waiting for a worker.
- Started: Worker picked it up.
- Finished / Failed: Terminal states.
error_message populated on failure.
- Canceled: Manually canceled; RQ job stopped if already Queued/Started.
- Auto-retry: When a task has
maxretries > 0 and retrycount < maxretries, the dispatcher automatically resets it to Pending after the retrydelay has elapsed since the last failure. retry_count is incremented each time a retry is triggered.
A realtime event asynctaskstatus is published to the creating user on every status transition (Queued, Started, Finished, Failed, Canceled). Use frappe.asynctasks.showprogress (see [JavaScript API](#javascript-api) below) for the idiomatic high-level approach. For raw access:
// Payload: { name, job_name, status, message, error, batch_id?, batch_done?, batch_total? }
// batch_id, batch_done, batch_total are only present when the task belongs to a batch.
// batch_done/batch_total are authoritative counters read from Redis — embedded in every batch task event.
// Always filter by name/batch_id — events are broadcast for ALL tasks.
frappe.realtime.on("async_task_status", ({ name, job_name, status, message, error, batch_id, batch_done, batch_total }) => { ... })
You can also push a custom message alongside a status update by calling notify_status() directly:
task.notify_status(message="Processing row 42 of 100...")
To send a progress notification from inside the executing method (where you don't have the task document), use the notifytaskstatus utility:
from tweaks.tweaks.doctype.async_task_log.async_task_log import notify_task_status
def my_long_running_job(items):
for i, item in enumerate(items):
process(item)
notify_task_status(message=f"Processed {i + 1} of {len(items)}")
notifytaskstatus resolves the current RQ job, looks up the matching Async Task Log, and calls notify_status() on it. It is a no-op when called outside a worker context.
Structured progress messages
Pass a dict with a progress key to emit granular progress information. The frappe.asynctasks.showprogress handler understands this format and will drive the progress bar with exact count/total values instead of the default status-based percentages.
notify_task_status(message={
"progress": {
"count": i + 1, # current step (used as the numerator)
"total": len(items), # total steps (used as the denominator)
"description": f"Processing item {i + 1} of {len(items)}…",
}
})
All three fields (count, total, description) are optional — include only the ones you need. When any field is absent the JS handler falls back to the default step-based value for that part of the progress bar.
JavaScript API
A thin JS namespace frappe.asynctasks is available on every desk page, provided by tweaks/public/js/tweaks/asynctasks.js.
frappe.asynctasks.showprogress(name, title?, handler?, hideoncompletion?)
High-level wrapper around frappe.realtime.on("asynctaskstatus", …) that drives a frappe.show_progress bar for a given task. Use this instead of wiring the raw realtime event by hand.
| Parameter |
Type |
Required |
Description |
name |
string |
Yes |
Async Task Log document name to track. |
title |
string |
No |
Progress bar title. Defaults to "Task {name}". |
handler |
function |
No |
Optional callback invoked on every status event: ({ name, status, message, error, job_name }) => void. |
hideoncompletion |
boolean |
No |
Whether to hide/close the bar on terminal status. Default true. |
Behaviour:
- Immediately shows the progress bar at 10 % ("Pending").
- Registers the
asynctaskstatus realtime listener so no transition event is missed.
- On Failed, calls
frappe.throw(error) to surface the error message in a dialog.
- The realtime listener is automatically deregistered once a terminal status (
Finished, Failed, Canceled) is received.
- When
message is an object with a progress property, the handler reads progress.count, progress.total, and progress.description to drive the progress bar with exact values. Any missing field falls back to the default step-based value.
Important: handler is called on every matching event (Pending, Queued, Started, Finished, Failed, Canceled), not only on terminal ones. If your handler should only fire once (e.g. to transition to a second phase), guard it with a status check.
// Enqueue via a server call, then track progress
frappe.call({
method: "myapp.api.start_import",
callback(r) {
frappe.async_tasks.show_progress(
r.message, // Async Task Log name
__("Importing data"), // progress bar title
({ status }) => {
if (status === "Finished") frappe.show_alert(__("Import complete!"))
}
)
},
})
Server-side structured progress (drives the bar with exact count/total):
# Inside the background method — no task document reference needed
from tweaks.tweaks.doctype.async_task_log.async_task_log import notify_task_status
def my_long_running_job(items):
for i, item in enumerate(items):
process(item)
notify_task_status(message={
"progress": {
"count": i + 1,
"total": len(items),
"description": f"Processing item {i + 1} of {len(items)}…",
}
})
frappe.asynctasks.showbatchprogress(batchid, title?, handler?, hideoncompletion?)
Pure batch progress tracker. Shows a frappe.showprogress bar that counts individual task completions using server-authoritative Redis counters embedded in every asynctask_status event. The client owns no counter state — missed events are harmless.
| Parameter |
Type |
Required |
Description |
batch_id |
string |
Yes |
Batch identifier returned by the server. |
title |
string |
No |
Progress bar title. Defaults to "Processing…". |
handler |
function |
No |
Called on every matching event: ({ batchid, status, message, batchdone, batchtotal, jobname }) => void. |
hideoncompletion |
boolean |
No |
Whether to hide/close the bar on completion. Default true. |
Behaviour:
- Immediately shows an indeterminate bar (
0 / 100, "Starting…") while waiting for the first event.
- Listens for
asynctaskstatus events; filters by batchid and drops events where batchtotal == null (tasks arrived before Redis keys were written).
- Each event carries
batchdone and batchtotal from Redis (set atomically by the server). The default bar label is "{jobname}: {statuslabel}" (or just "{statuslabel}" when no jobname). Structured msg.progress overrides count/total/description as usual.
handler is called on every matching event (Pending, Queued, Started, Finished, Failed, Canceled). Guard with status === 'Finished' (or a count check) if you only want to act once on completion.
- The realtime listener is deregistered when a terminal-status event brings
batchdone >= batchtotal.
- Does not handle the coordinator task — that is the caller's responsibility (see below).
Usage:
frappe.async_tasks.show_batch_progress(
batch_id,
__("Creating records"),
({ status, batch_done, batch_total }) => {
if (status !== 'Finished' || batch_done < batch_total) return
frappe.show_alert({
message: __("{0} of {1} created", [batch_done, batch_total]),
indicator: "green",
})
listview.refresh()
},
)
Two-phase coordinator → batch pattern
When a coordinator task resolves a list of items and then calls bulkenqueueasync_task to fan out, orchestrate the two phases in the caller:
// Server returns { batch_id, coordinator_task_name } synchronously.
frappe.call({
method: "myapp.api.bulk_create",
callback(r) {
const { batch_id, coordinator_task_name } = r.message
const onBatchComplete = ({ status, batch_done, batch_total }) => {
if (status !== 'Finished' || batch_done < batch_total) return
frappe.show_alert({
message: __("{0} of {1} created", [batch_done, batch_total]),
indicator: "green",
})
listview.refresh()
}
// Phase 1: show coordinator bar. On Finished, transition to batch bar.
frappe.async_tasks.show_progress(
coordinator_task_name,
__("Creating records"),
({ status }) => {
if (status === "Finished")
frappe.async_tasks.show_batch_progress(batch_id, __("Creating records"), onBatchComplete)
},
false, // don't auto-hide Phase 1 bar — Phase 2 will take over
)
},
})
Key point: Guard the showbatchprogress call with status === "Finished" because handler in showprogress is called on every event. Without the guard, showbatch_progress would be registered once per event (Pending, Queued, Started, Finished), leading to duplicate completion callbacks.
Server-side coordinator pattern:
import uuid
from tweaks.tweaks.doctype.async_task_log.async_task_log import (
enqueue_async_task,
bulk_enqueue_async_task,
notify_task_status,
)
@frappe.whitelist()
def bulk_create(items):
batch_id = str(uuid.uuid4())
coordinator = enqueue_async_task(
"myapp.api._coordinator",
queue="short",
job_name="Bulk Create",
arguments={"items": items, "batch_id": batch_id},
)
return {"batch_id": batch_id, "coordinator_task_name": coordinator.name}
def _coordinator(items, batch_id):
notify_task_status(message=f"Resolved {len(items)} items. Enqueueing…")
tasks = [{"method": "myapp.api.process_item", "batch_id": batch_id, "arguments": {"item": i}} for i in items]
bulk_enqueue_async_task(tasks, batch_id=batch_id)
# bulk_enqueue_async_task writes Redis counters (total + done=0) with a 24 h TTL.
# Batch task events will embed batch_done/batch_total from those keys automatically.
Cancellation
task = frappe.get_doc("Async Task Log", task_name)
task.cancel() # works from Pending, Queued, or Started
Retry
Failed tasks can be retried manually or automatically.
Manual retry
task = frappe.get_doc("Async Task Log", task_name)
task.retry() # reset to Pending and trigger dispatch
task.retry(now=True) # enqueue for immediate execution (skip dispatcher)
retry() raises if called on a non-Failed/non-Canceled task.
Automatic retry
Set maxretries (and optionally retrydelay) when creating the task:
task = enqueue_async_task(
"myapp.utils.sync_customer",
max_retries=3,
retry_delay=60, # wait 60 seconds after failure before retrying
customer_id="CUST-001",
)
The dispatcher calls retryfailedtasks() at the start of every dispatch pass. It queries all Failed tasks where maxretries > 0 and retrycount < maxretries, then resets those whose retrydelay has elapsed since their modified timestamp. Each retry increments retrycount. When retrycount reaches max_retries the task stays Failed and is no longer picked up automatically.
Key points:
retry_count tracks how many automatic (or manual) retries have been triggered, not how many failures occurred.
retry_delay=None (or 0) means retry on the very next dispatch cycle.
- Per-task errors in
retryfailedtasks are logged and do not block retries of other tasks.
Observability
Each Async Task Log document stores:
job_name — human-readable label used as the document title (defaults to method)
startedat, endedat, time_taken
peakmemoryusage (RSS, KB)
error_message with full traceback on failure
debuglog if frappe.debuglog is populated
job_id linking to the underlying RQ Job
documenttype, documentname, document_action — persisted when created via the document action shorthand; used at execution time to re-fetch the document and call the action
maxretries, retrydelay, retry_count — retry configuration and current retry counter
Dispatcher Control
The dispatcher can be suspended site-wide, which prevents any new tasks from being promoted to Queued. Already-Queued workers continue running.
toggle_dispatcher (whitelisted)
Requires System Manager role. Persists the suspended/running state as a site default so it survives process restarts.
from tweaks.tweaks.doctype.async_task_log.async_task_log_dispatch import toggle_dispatcher
toggle_dispatcher(enable=False) # suspend — no new tasks will be dispatched
toggle_dispatcher(enable=True) # resume — dispatcher runs normally again
Can also be called via the HTTP API (it is @frappe.whitelist()):
POST /api/method/tweaks.tweaks.doctype.async_task_log.async_task_log_dispatch.toggle_dispatcher
{ "enable": 1 } // or 0
candispatchnow
Returns True when the dispatcher is running (not suspended). Use this guard before triggering dispatch manually:
from tweaks.tweaks.doctype.async_task_log.async_task_log_dispatch import can_dispatch_now
if can_dispatch_now():
enqueue_dispatch_async_tasks()
Note: bulkenqueueasynctask internally suspends the dispatcher while inserting tasks and calls setdispatcherstate directly (bypassing the System Manager permission check that wraps the public toggledispatcher). Never call toggledispatcher from inside a background worker for internal use — use setdispatcher_state instead.
Dispatch & Recovery
Tasks are never dropped. The dispatcher runs:
- After every new task insert (
after_insert hook)
- After every task completes (success or failure)
- Via the scheduler (
all event) as a recovery mechanism for missed triggers
See [references/implementation.md](references/implementation.md) for the full dispatch algorithm and concurrency internals. See [references/comparisonwithpreparedreport.md](references/comparisonwithpreparedreport.md) for a side-by-side schema and implementation comparison with Frappe's built-in Prepared Report. See [references/comparisonwithrqjob.md](references/comparisonwithrqjob.md) for a side-by-side schema and lifecycle comparison with Frappe's built-in RQ Job virtual DocType. See [references/comparisonwithscheduledjob.md](references/comparisonwithscheduledjob.md) for a side-by-side schema and implementation comparison with Frappe's Scheduled Job Type / Scheduled Job Log.
Source Code
tweaks/tweaks/doctype/asynctasklog/asynctasklog.py — Public API + Document controller
tweaks/tweaks/doctype/asynctasklog/asynctasklog_dispatch.py — Dispatch algorithm
tweaks/tweaks/doctype/asynctasktype/asynctasktype.py — Type configuration