name: apex-transaction-finalizers description: "Use this skill when you need guaranteed post-Queueable cleanup, retry, or failure-logging logic that must run even when the parent Queueable throws an unhandled exception. Trigger keywords: FinalizerContext, System.attachFinalizer, Queueable cleanup on failure, post-job compensation, guaranteed async cleanup. NOT for batch job completion callbacks — use apex-batch-chaining. NOT for platform event publishing on failure — use platform-events-apex." category: apex salesforce-version: "Summer '21+ (API v53.0+)" well-architected-pillars:
- Reliability triggers:
- "queueable cleanup on failure apex"
- "transaction finalizer run after exception"
- "FinalizerContext getResult SUCCESS apex" tags:
- apex-finalizer
- queueable
- error-handling
- async-apex
- cleanup inputs:
- "The Queueable class that needs guaranteed post-execution behavior"
- "The failure scenario: retry, compensate, or log"
- "Retry count limit if implementing retry logic" outputs:
- "A System.Finalizer implementation attached to the parent Queueable"
- "Retry Queueable enqueue (one job max) or failure record DML"
- "Review checklist confirming Finalizer constraints are respected" dependencies:
- apex-queueable-patterns version: 1.0.0 author: Pranav Nagrecha updated: 2026-04-19
Apex Transaction Finalizers
This skill activates when a Queueable job needs guaranteed post-execution behavior — cleanup, retry, or failure logging — that must run even if the parent Queueable throws an unhandled exception. Use System.attachFinalizer() to bind a System.Finalizer implementation to a Queueable; the Finalizer runs in a separate Apex transaction with full governor limits after the parent job finishes.
Before Starting
Gather this context before working on anything in this domain:
- Confirm the parent job is a
Queueable(not Batch, Scheduled, or@future). Transaction Finalizers are only supported on Queueable jobs. - Identify the failure scenario: Is this a retry (re-enqueue the same job), a compensation (write a failure record / publish a PE), or silent logging?
- Determine a retry ceiling. Finalizers can enqueue exactly one new Queueable — if that Queueable also has a Finalizer, the chain continues. Without a retry limit you risk infinite loops.
- Check the API version of the Queueable class —
System.attachFinalizer()requires API v53.0+ (Summer '21). - The Finalizer does not run if the parent job was aborted via
System.abortJob(). Plan for that case separately.
Core Concepts
Finalizer Lifecycle
When System.attachFinalizer(myFinalizer) is called inside a Queueable's execute() method, the platform registers the Finalizer to execute after the parent job's transaction closes — whether it committed successfully or was rolled back due to an unhandled exception. The Finalizer runs in a completely separate Apex transaction with fresh governor-limit counters (100 SOQL queries, 150 DML statements, etc.). The parent transaction's state (variable values, uncommitted DML) is not visible to the Finalizer.
FinalizerContext API
The Finalizer's execute(FinalizerContext ctx) method receives a FinalizerContext object with three key members:
| Member | Returns | Notes |
|---|---|---|
ctx.getJobId() | Id | The AsyncApexJob ID of the parent Queueable |
ctx.getResult() | System.ParentJobResult | SUCCESS or UNHANDLED_EXCEPTION |
ctx.getException() | Exception | Non-null only when getResult() == UNHANDLED_EXCEPTION |
Always gate retry / compensation logic on ctx.getResult() to avoid double-processing on success.
Enqueue Constraint
A Finalizer may enqueue exactly one new Queueable job via System.enqueueJob(). Attempting to enqueue more than one throws a System.AsyncException. A Finalizer cannot attach another Finalizer to itself — System.attachFinalizer() called from within a Finalizer context throws a System.AsyncException.
Abort Gap
If the parent Queueable is terminated via System.abortJob(), the Finalizer is not invoked. This is a hard platform constraint with no workaround at the Finalizer layer. If abort-path cleanup is required, model it as a separate Schedulable or monitoring job that polls AsyncApexJob for ABORTED status.
Common Patterns
Retry-on-Failure with Backoff Counter
When to use: A Queueable makes an external callout or complex DML that can fail transiently. You want automatic retry up to N times without manual re-queuing.
How it works:
- Pass a
retryCountinteger into the Queueable constructor. - Inside
execute(), callSystem.attachFinalizer(new MyFinalizer(jobPayload, retryCount)). - In the Finalizer's
execute(), checkctx.getResult(). OnUNHANDLED_EXCEPTIONandretryCount < MAX_RETRIES, enqueue a new instance of the Queueable withretryCount + 1. - On
retryCount >= MAX_RETRIES, write a failure record instead of re-enqueuing.
Why not try/catch inside execute(): A try/catch inside execute() only catches exceptions thrown by code in that block — governor-limit violations and some system exceptions escape it. A Finalizer provides an out-of-band, guaranteed callback even for unhandled exceptions that bypass catch blocks.
Failure Logging to Custom Object
When to use: You need an auditable record of every Queueable failure for operations monitoring, SLA reporting, or manual reprocessing.
How it works:
- Attach a Finalizer that receives the job context (record IDs, batch key, etc.) from the parent Queueable constructor.
- In
execute(ctx), ifctx.getResult() == UNHANDLED_EXCEPTION, insert anAsync_Job_Error__c(or equivalent) record with the job ID, exception message, stack trace, and payload snapshot. - Use the single Queueable enqueue slot only if retry is also needed; otherwise leave it unused.
Why not System.debug: Debug logs are transient and unavailable to non-admin users. A custom object record survives platform restarts and is queryable by monitoring tools.
Decision Guidance
| Situation | Recommended Approach | Reason |
|---|---|---|
| Parent Queueable fails transiently (callout timeout, lock contention) | Retry Finalizer with counter | Full governor limits in separate transaction; single enqueue slot used for the retry job |
| Failure needs permanent audit record | Logging Finalizer (DML in separate transaction) | Parent transaction is rolled back; Finalizer gets fresh DML budget |
| Both retry AND logging needed | Single Finalizer handles both; log first, then conditionally enqueue retry | Enqueue limit is 1 — combine both behaviors in one Finalizer |
| Parent job was aborted by admin | Schedulable monitor polling AsyncApexJob for ABORTED status | Finalizer does not fire on abort; no workaround |
| Batch job completion callback | Database.Batchable finish() method or apex-batch-chaining skill | Transaction Finalizers are Queueable-only |
| Publishing a Platform Event on failure | PE publish inside Finalizer OR dedicated PE skill | PE publish counts against Finalizer's DML budget; prefer dedicated skill for complex routing |
Recommended Workflow
- Confirm Queueable context — verify the failing async job is a
Queueable, API v53+, and that abort-path behavior does not need to be covered by this Finalizer. - Choose Finalizer behavior — decide between retry, compensation DML, or both. If both, plan the single Finalizer class that handles them sequentially.
- Design the retry ceiling — pick
MAX_RETRIES(typically 3–5) and passretryCountthrough the Queueable constructor so the Finalizer can increment and re-enqueue safely. - Implement
System.Finalizer— create a class thatimplements System.Finalizer, receives the job payload via constructor, implementsexecute(FinalizerContext ctx), gates onctx.getResult(), and enqueues at most one retry job. - Attach in
execute()— callSystem.attachFinalizer(new MyFinalizer(...))near the top of the parent Queueable'sexecute()method so it is registered before any code that might throw. - Test both SUCCESS and UNHANDLED_EXCEPTION paths — use
Test.startTest()/Test.stopTest()to flush the queue; mock the failure by having the Queueable throw in test context, and assert the Finalizer's DML/enqueue behavior. - Review checklist — confirm no second
attachFinalizercall, retry counter bounded, noattachFinalizerinside the Finalizer itself.
Review Checklist
-
System.attachFinalizer()is called exactly once per Queueableexecute()invocation - Finalizer gates all compensation logic on
ctx.getResult() == System.ParentJobResult.UNHANDLED_EXCEPTION - Retry counter is passed via constructor and incremented before re-enqueuing;
MAX_RETRIESceiling is enforced - The Finalizer enqueues at most one new Queueable job (throws
AsyncExceptionif you try more) - No call to
System.attachFinalizer()inside the Finalizer's ownexecute()method - Tests cover both SUCCESS and UNHANDLED_EXCEPTION result paths
- Abort-path (if required) is handled by a separate mechanism — Finalizer does not fire on
System.abortJob()
Salesforce-Specific Gotchas
- Finalizer does not fire on
System.abortJob()— If an admin or another job callsSystem.abortJob(parentJobId), the Finalizer is silently skipped. This is undocumented in some sources but confirmed in the official Apex Developer Guide. Any cleanup that must happen on abort needs a separate polling mechanism. - Parent transaction rollback is total — When the Queueable throws an unhandled exception, every DML operation in that transaction is rolled back. The Finalizer starts with a clean slate — it cannot read variables set in the parent, and it cannot "see" records that the parent tried but failed to commit.
- One enqueue, no exceptions — Calling
System.enqueueJob()more than once in a single Finalizerexecute()call throwsSystem.AsyncExceptionimmediately. Wrap the retry call in a conditional so it is only reached when retry is actually needed. - Finalizer exception is swallowed — If the Finalizer itself throws an unhandled exception, the platform logs it to
ApexLogbut does not propagate it anywhere visible. There is no secondary Finalizer. Build explicit logging inside the Finalizer's ownexecute()using atry/catchwrapper.
Output Artifacts
| Artifact | Description |
|---|---|
System.Finalizer implementation class | Apex class implementing System.Finalizer with retry and/or logging logic |
| Updated Queueable class | Parent Queueable with System.attachFinalizer() call and retryCount constructor param |
Async_Job_Error__c insert (optional) | Custom object record capturing job ID, exception type, message, and stack trace |
Related Skills
- apex-queueable-patterns — foundational Queueable design; use alongside this skill for the parent job structure
- apex-batch-chaining — for batch-to-batch chaining; Finalizers do not apply to Batch jobs
- apex-limits-monitoring — for monitoring Apex governor limits that might cause the Queueable to fail in the first place