Skip to content

Add logger, requeue, config safety and agent docs for 3.0.0 - #63

Merged
Mateusz7410 merged 8 commits into
mainfrom
feature/async-0010
Sep 15, 2026
Merged

Mateusz7410 merged 8 commits into
mainfrom
feature/async-0010

Conversation

@Mateusz7410

Copy link
Copy Markdown
Collaborator

Summary

Second half of 3.0.0. The breaking change (jobs must declare how state resets between runs) and the Async.Backoff factories are already on main; this PR carries the rest.

  • Pluggable logger and lifecycle events (Pluggable global logger for async jobs (Nebula/DML-Lib style) + per-job lifecycle hooks #58). Register one global class in QueueableJobSetting__mdt.LoggerClass__c and every job reports to it. Four capability interfaces, Async.OnJobEnqueued / OnJobSucceeded / OnJobFailed / OnRetryEnqueued, usable on a job directly or on the registered class. info(key, value) attaches metadata that arrives on every context. A listener that throws never affects the job. Logging
  • Requeue (Requeue failed jobs from AsyncResult__c #50). Async.requeue(resultIds) rebuilds failed jobs from a snapshot stored on AsyncResult__c and runs them as one chain. Opt-in through StoreJobPayload__c, a picklist so a job record can say No against an org-wide Yes. Works with CreateResult__c off: storing a payload writes a row for failed and skipped jobs on its own, never for successful ones. Packaged installs register one global Async.JobSerializer, ready to copy from extras/, because JSON cannot cross a namespace boundary in either direction. Four new fields, JobPayload__c, PayloadSize__c, RequeueStatus__c, RequeuedFrom__c. Requeue
  • Custom Metadata mistakes degrade instead of halting the org. Three settings on the All record could throw from enqueue and stop every async job at once. Each now falls back to the safe behaviour and records why in RetryHistory__c. Mistakes written in Apex still throw. Configuration Safety
  • AsyncMock.jobSettings(...) injects QueueableJobSetting__mdt records in tests, so all ten settings are testable by consumers without real Custom Metadata.
  • Agent cheat-sheet (Make Async Lib AI-agent friendly: llms.txt / llms-full.txt + curated Agent cheat-sheet #59). website/ai-usage.md: the whole public surface on one page with recipes and gotchas, signatures taken from the API files. llms.txt now points agents at it. Async Lib for AI Agents
  • Installation split. A hub with a package-vs-source table, Installing as a Package with every namespace-specific step in one checklist, and Deploying the Source. The Deploy button pins the release tag and release.sh bumps it alongside the package id.
  • Layouts fixed. AsyncResult__c showed 4 of 15 fields, without Status__c or ExceptionMessage__c; QueueableJobSetting__mdt showed 3 of 8. Every field is on its layout now, with related lists for DependsOnResult__c and RequeuedFrom__c.
  • QueueableJob.className is no longer transient. It was recomputed on every hop for every job by throwing a TypeException, 200 exceptions per hop in a 200-job chain.
  • Internals: GlobalClassFactory builds any class named in Custom Metadata, QueueableManager.settingFor replaces three copies of the job-record-or-All lookup, AsyncTest reordered into fields, tests, helpers, inner types.

release-notes/v3.0.0.md is the full release text, breaking change included, with a link from every entry to the page that goes deeper.

Why

Closes the five-issue scope for 3.0.0. The logger and requeue are the two operational features the release was planned around; the config-safety fix came out of asking what else a single Custom Metadata typo could take down; the layouts, the cheat-sheet and the installation split fell out of doing those properly.

Test plan

  • bash scripts/agent/verify.sh static passed (prettier + eslint + PMD)
  • bash scripts/agent/verify.sh full passed, 307/307 at 97%
  • bash scripts/agent/verify.sh ns passed, 27/27
  • Step 6b: unlocked package built from a globalized copy with code coverage, installed into a --no-namespace scratch, package-tests/consumer-app and extras/classes deployed, 37/37 consumer tests against the installed package. Throwaway version deleted from the dev hub.
  • After the site deploys: open /llms.txt and /ai-usage.md once.

Files

force-app/main/default/classes/AsyncTest.cls                       | 3510 +++++++++++++-------
website/ai-usage.md                                                |  349 ++
release-notes/v3.0.0.md                                            |  237 +-
force-app/main/default/classes/queue/AsyncRequeue.cls              |  215 ++
website/explanations/requeue.md                                    |  209 ++
website/explanations/logging.md                                    |  185 ++
website/introduction/packaged-install.md                           |  134 +
package-tests/consumer-app/.../classes/NsTestRequeue.cls           |  134 +
force-app/main/default/classes/queue/QueueableChain.cls            |  133 +-
force-app/main/default/classes/queue/AsyncEventDispatcher.cls      |  127 +

56 files, +5,458 / -1,363. Most of the AsyncTest.cls count is the reorder; content is line-for-line identical apart from the 26 new tests.

Closes #50
Closes #58
Closes #59

QueueableJobSetting__mdt is editable in production with no deploy and no test
run, and the "All" record reaches every job in the org. Three settings could
throw from addJob, so a single typo there stopped every async job at once:
MaxRetries__c above the cap, an unknown BackoffStrategy__c, and, since the
state gate landed, MaxRetries__c on any job that had not declared how its state
resets. The last one is the worst, because turning retry on org-wide is a
reasonable thing for an admin to try.

Configuration is now treated as untrusted input from a live system. Each case
degrades to the safe behaviour, records the reason on the job's retry history so
it reaches AsyncResult__c.RetryHistory__c, and writes it to the debug log:
retry is not applied, backoff is dropped, the count is clamped. Every fallback
degrades toward doing less, never toward doing something the developer did not
ask for. Skipping retry leaves the job running exactly once, which is what its
code was written and tested against; silently enabling state-carrying retry
would not have been safe.

Mistakes written in Apex still throw, unchanged. A developer calling retry(n)
without a declared reset gets the same loud gate as before, because it is their
own code, it fails in their own tests, and the blast radius is one class.

Two tests that asserted the old throwing behaviour are removed rather than
converted, since the behaviour they pinned no longer exists. A new test pins
that the code-triggered path still throws, so the asymmetry itself is covered.

website/explanations/configuration-safety.md explains why the two are treated
differently, since on the surface it reads as an inconsistency.
Closes #58.

Standardising on an external logger meant overriding onFinalFailure in every job,
maintaining a shared base class and relying on discipline, or bolting a trigger
onto AsyncResult__c and storing records nobody wanted. Registering one class in
QueueableJobSetting__mdt.LoggerClass__c now routes every job to it, with the All
record covering the org and per-job records overriding it, reusing the lookup
retry settings already use. No new metadata type.

Events are four separate capability interfaces rather than one base class or one
fat interface. Apex has no default methods, so a fat interface could never gain a
fifth member without breaking every implementer at install time, and a base class
would spend the consumer's single inheritance slot on a logging adapter. Separate
interfaces mean a fifth event is additive and a logger implements only what it
wants. The same interfaces work directly on a QueueableJob, so per-job hooks
needed no new members on the class every consumer extends.

The registered class must be declared global. Measured on a real package install:
Type.forName from package code resolves a global subscriber class through either
the one-arg or empty-namespace form, and returns null for a public one. The 'c'
namespace alias does not work. Only the class needs global, not its methods.

info() attaches per-invocation key/value metadata that arrives on every context.
A global logger is handed an interface-typed listener and cannot cast to a
concrete job to read its fields, still less across a namespace, so this is the
only channel from the enqueue site to the org-wide sink. Being per invocation
rather than per class, the same job enqueued by two flows can be attributed
differently. FailureContext also gains nextAttemptDelayMinutes.

A listener that throws is caught and reported; the job has already done its work
by the time most events fire, so failing it would turn an observability problem
into a data problem. A LoggerClass__c that cannot be resolved degrades to no
logging and records why, per the configuration safety rule.

docs/code-style.md writes down the comment policy that pmd/ruleset.xml already
referenced but that existed nowhere in the repo, and links it from the PR
checklist.
Custom Metadata cannot be inserted in Apex, and the injection point the framework
used was a @testvisible member on QueueableChain, which is not global. So a
subscriber could not test any QueueableJobSetting__mdt behaviour: retry defaults,
backoff, retryable exceptions, result creation, disabled jobs, or the logger
registered in the previous commit. Step 6b caught this, because the consumer test
for logger routing would not even compile against an installed package.

AsyncMock.jobSettings(List<QueueableJobSetting__mdt>) is the way in. AsyncMock is
already the consumer-facing testing class and is @istest, so the guard against
production use is structural rather than a Test.isRunningTest() throw.

Settings now live in one static property that lazily loads Custom Metadata unless
something has been injected, replacing a per-chain @testvisible property, a branch
on Test.isRunningTest() in the read path, and a second parallel seam. The setter
is private with @testvisible on the property, so production code in the namespace
cannot assign org configuration by accident. Note that @testvisible has no effect
when placed on the accessor; it has to sit on the property declaration, and the
difference compiles either way.

The old getter rebuilt the map on every read, and it is read by resolveRetrySetting,
loggerClassFor and resultEnabledFor, so a chain of N jobs rebuilt it many times per
transaction. It is now built once, which is the correct scope because Custom
Metadata cannot change mid-transaction.

The 21 framework tests that assigned the per-chain property move to the static, so
there is one seam rather than two, and NsTestLogging drops its reach into
QueueableChain. That test now uses the btcdev__ prefixed Custom Metadata form,
which resolves both in a namespaced org and in a subscriber org, so a single file
exercises the same API a real consumer writes. Writing consumer tests against a
path consumers cannot use is what hid this defect in the first place.
AsyncResult__c showed 4 of its 15 fields, without Status__c or
ExceptionMessage__c, so an admin opening a failed job saw nothing useful.
QueueableJobSetting__mdt showed 3 of 8, hiding retry, backoff and the
LoggerClass__c that aae5446 shipped. Neither had a related list.

Every field is on its layout now, grouped, and AsyncResult__c gains a
related list for DependsOnResult__c. CONTRIBUTING gets the checklist line
that would have caught this.
Async.requeue(resultIds) rebuilds jobs from a snapshot stored on their
result row and runs them again as one chain. The snapshot is the job as
it was handed to enqueue(), taken before any chain bookkeeping, so a
replay starts where the original did. Storage is opt-in through
QueueableJobSetting__mdt.StoreJobPayload__c, a picklist so a job record
can say No against an org-wide Yes. Storing a payload writes a row for
failed and skipped jobs regardless of CreateResult__c; successful rows
never carry one, so a bulk replay cannot re-run a job that worked.

New on AsyncResult__c: JobPayload__c, PayloadSize__c, RequeueStatus__c,
RequeuedFrom__c. Selection goes through RequeueStatus__c because Long
Text cannot be filtered on, and Requeued on the source is what stops a
scheduled replay picking a row up twice. Payloads are read in a second
FOR UPDATE query only after the 2,000,000-character budget passes.

JSON cannot cross a namespace boundary in either direction, so a
packaged install registers one global Async.JobSerializer in
JobSerializerClass__c; extras/classes/AsyncJobSerializer.cls is ready to
copy. GlobalClassFactory now builds any class named in Custom Metadata,
for the logger and the serializer alike, and QueueableManager.settingFor
replaces three copies of the job-record-or-All lookup.

QueueableJob.className is no longer transient. It was recomputed on
every hop for every job by throwing a TypeException, 7,400 times in a
37-hop test.

Docs: a requeue explanation, and installation split into a hub with one
guide for the package and one for a source deploy. The deploy button now
pins the release tag and release.sh bumps it with the package id.
AsyncTest is reordered: fields, tests, helpers, inner types.

Closes #50
website/ai-usage.md is the whole public surface on one page: every
Async.* entry point, every builder method, what you implement, what you
get back, the Custom Metadata fields, AsyncResult__c, AsyncMock, seven
recipes and the gotchas an agent gets wrong on the first try. Signatures
come from the API files, not from memory.

llms.txt and llms-full.txt were already generated from the sidebar; the
new page is listed there and the index now opens by pointing agents at
it. CONTRIBUTING gets the rows that keep the page, the consumer tests,
the extras tables and the sidebar in step with the code.

Closes #59
The notes covered the breaking change, the logger, requeue and the
layout fix, and missed the rest of the release: the Async.Backoff
factories, three of the four fixes (rollback leak, silent deepClone
retry, Custom Metadata landmines), the className recompute, the agent
cheat-sheet with llms.txt, the installation split and PMD in CI. Every
entry links to the page that goes deeper.
@vercel

vercel Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
async-lib Ready Ready Preview Sep 15, 2026 6:09pm UTC

Request Review

@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

🧪 Apex Test Results

✅ All Tests Passed

==========================================
     APEX TEST EXECUTION SUMMARY
==========================================

📊 Total Tests: 310
✅ Passed: 310
❌ Failed: 0
⏭️  Skipped: 0


🎉 All tests passed successfully!

📦 Download Full Test Results & Logs


📊 Stats: 310 total | ✅ 310 passed | ❌ 0 failed
🤖 Automated comment by Salesforce CI

@codecov

codecov Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.11%. Comparing base (466c078) to head (34176f2).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #63      +/-   ##
==========================================
+ Coverage   98.54%   99.11%   +0.56%     
==========================================
  Files          23       27       +4     
  Lines        1581     1919     +338     
==========================================
+ Hits         1558     1902     +344     
+ Misses         23       17       -6     
Flag Coverage Δ
Apex 99.11% <100.00%> (+0.56%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Codecov reported 34 patch lines with no test. Most were paths the
consumer-app suite exercises but the source run never did: info() on
both builders, the single-id requeue overload, the empty-set return, a
logger whose constructor throws, and the two-argument type lookup for
an inner class name.

The last four were the re-check after the row lock, reachable only when
two transactions requeue the same rows at once, which an Apex test
cannot produce. The lock now happens in the first query instead of the
second, so the re-check has nothing left to check and is gone.

Claude-Session: https://claude.ai/code/session_01WXtSWhbJQUNGyguG7x3uor
@Mateusz7410
Mateusz7410 merged commit 5dc0c91 into main Sep 15, 2026
6 checks passed

This branch was successfully deployed

1 active deployment
Preview — 34176f24 Deployed Sep 15, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant