kehwar/frappe_tweaks · Archived

frappe-tweaks-async-tasks

Expert guidance for enqueueing and managing Async Tasks in Frappe Tweaks. Use when working with enqueue_async_task, enqueue_safe_async_task, bulk_enqueue_async_task, bulk_enqueue_safe_async_task, toggle_dispatcher, Async Task Log, Async Task Type, implementing concurrency limits, per-method priority ordering, task cancellation, batch/bulk task submission, document action tasks (document_type/document_name/document_action), dispatcher control, auto-retry on failure (max_retries, retry_delay, ret…

First seen Jun 19, 2026

Installation

$ npx skills add kehwar/frappe_tweaks --skill frappe-tweaks-async-tasks

Summary

  • Expert guidance for enqueueing and managing Async Tasks in Frappe Tweaks.
  • Use when working with enqueue_async_task, enqueue_safe_async_task, bulk_enqueue_async_task, bulk_enqueue_safe_async_task, toggle_dispatcher, Async Task Log, Async Task Type, implementing concurrency limits, per-method priority ordering, task cancellation, batch/bulk task submission, document action tasks (document_type/document_name/document_action), dispatcher control, auto-retry on failure (max_retries, retry_delay, retry_count), or choosing between Async Tasks and standard frappe.enqueue background jobs.

Stronger alternatives

This repository is archived — consider an actively maintained alternative.

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from kehwar/frappe_tweaks · top by installs.

npx skills add kehwar/frappe_tweaks

Browse all from kehwar/frappe_tweaks

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

License license.txt
Default branch main
Open issues 0
Status Archived

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 25,586 B
  • docs SUMMARY.md 619 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 2 installs

SKILL.md

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:

  1. If batch_id is not given, a random UUID is assigned so all tasks share the same batch.
  2. Each task's batch_order is set sequentially (0, 1, 2 …) in insertion order.
  3. The dispatcher is internally suspended while inserting to prevent partial dispatches, then resumed atomically once all documents are committed.
  4. 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:

  1. frappe.getdoc(documenttype, document_name) is called inside the worker.
  2. doc.unlock() is called to release any file lock left from the caller.
  3. Inner function resolution: if doc.submit exists and documentaction="submit", _submit is called instead.
  4. 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:

  1. After every new task insert (after_insert hook)
  2. After every task completes (success or failure)
  3. 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