Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,17 @@ Please include:
Before submitting your PR, make sure you've:

- [ ] Written clear and concise commit messages
- [ ] Followed existing code style and naming conventions
- [ ] Followed the [code style](docs/code-style.md), in particular: code is self-explaining and comments are a last resort
- [ ] Added or updated relevant documentation (if applicable)
- [ ] Added or updated unit tests (if applicable)
- [ ] Verified that all existing tests pass (`npm test`)
- [ ] Run linting and formatting (`npm run lint`, `npm run prettier`)
- [ ] Updated the documentation site if needed
- [ ] Updated the documentation site if needed, and for any public API change also `website/ai-usage.md`, the one-page contract agents read (take signatures from the code, not from memory)
- [ ] Added a consumer-perspective test in `package-tests/consumer-app` for any new `global` surface
- [ ] Checked [API evolution rules](docs/api-evolution.md) if you touched a `global` type or added an extension point
- [ ] Added every new field to its page layout in `force-app/main/default/layouts` and, for `AsyncResult__c`, to `AsyncResultAccess` (a field missing from either is invisible to admins)
- [ ] Added any new `extras/` class to `extras/README.md` and to the tables in `website/introduction/packaged-install.md`
- [ ] Added any new website page to the sidebar in `website/.vitepress/config.mts` (`llms.txt` is generated from it)

## 📝 Types of Contributions

Expand Down
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,35 @@ Visit https://async.beyondthecloud.dev/ to view the full documentation.
- **Custom Metadata Configuration**: Configure the QueueableJob settings using the `QueueableJobSettings__mdt` custom metadata type to enable or disable jobs, and to control the creation of Async Result records.
- **Custom Object for Async Results**: The `AsyncResult__c` custom object is created for each processed queueable job, allowing you to track the chained job status and details.

## Deploy to Salesforce
## Installation

<a href="https://githubsfdeploy.herokuapp.com?owner=beyond-the-cloud-dev&repo=async-lib&ref=main">
Two ways in. Pick one.

### Unlocked package

<a href="https://async.beyondthecloud.dev/introduction/installation">
<img alt="Install Unlocked Package" src="https://img.shields.io/badge/Install-Unlocked%20Package-blue?style=for-the-badge&logo=salesforce">
</a>

Versioned, uninstallable, upgrades by installing the next version. Every class carries the `btcdev.` prefix and a few features need a class copied from [`extras/`](./extras). Guide: [Installing as a Package](https://async.beyondthecloud.dev/introduction/packaged-install).

### Source deploy

<a href="https://githubsfdeploy.herokuapp.com?owner=beyond-the-cloud-dev&repo=async-lib&ref=v2.8.0">
<img alt="Deploy to Salesforce"
src="https://raw.githubusercontent.com/afawcett/githubsfdeploy/master/deploy.png">
</a>

Or with the CLI:

```bash
git clone https://github.com/beyond-the-cloud-dev/async-lib.git
cd async-lib
sf project deploy start --source-dir force-app --target-org your-org
```

No namespace, nothing extra to set up, upgrades by redeploying the next tag. Guide: [Deploying the Source](https://async.beyondthecloud.dev/introduction/source-deploy).

## Contributors

<a href="https://github.com/beyond-the-cloud-dev/async-lib/graphs/contributors">
Expand Down
93 changes: 93 additions & 0 deletions docs/code-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Code Style

## Comments

**Code must be self-explaining. A comment is a last resort, not a default.**

Classes, methods, fields and variables carry the meaning. If a comment feels
necessary, that is almost always a naming or structure problem, so fix the code
instead:

| Instead of a comment saying | Do this |
| -------------------------------------------- | ------------------------------------------------------------ |
| what a block does | extract it into a method whose name says it |
| why a `catch` swallows | name the handler method, `reportWithoutAffectingTheJob(...)` |
| that a static resets each transaction | name the field, `loggerCacheForThisTransaction` |
| that a class must be `global` to be resolved | name the method, `newInstanceOfGlobalClass(...)` |
| what a flag means | name the variable, `retryWillRestore` |

Delete on sight: comments restating the code, section banners, commented-out
code, narration ("first we...", "now handle..."), and ApexDoc that only echoes
the signature.

### The bar for keeping one

A comment earns its place only when a competent Apex developer would be
**surprised or misled** without it, and no name or structure can carry it. In
practice that means a platform quirk or a deliberate choice that looks wrong:

```apex
// A failed cast is the only way to read the runtime type with its namespace.
String.valueOf((DateTime) job);
```

```apex
// List.sort() does not define the order of equal elements, so equal priority needs an
// explicit tiebreak to keep jobs running in the order they were chained.
```

Rules of thumb that stay in prose belong in `website/explanations/`, not in the
source. If the reason is about **how consumers use the library**, document it
there and link it from the error message. If it is about **how the framework may
evolve**, it belongs in `docs/api-evolution.md`.

### The two allowed exceptions

**PMD suppression justification.** Every `@SuppressWarnings` carries a header
block saying why the rule is a false positive here. Without it a suppression is
indistinguishable from hiding a defect.

```apex
/**
* PMD False Positives:
* - ExcessivePublicCount: one fluent method per job option
**/
@SuppressWarnings('PMD.ExcessivePublicCount')
```

**A member that must never be deleted.** Where the reason for keeping
dead-looking code is invisible, say so, because the next maintainer will
otherwise remove it.

```apex
/**
* Superseded by Async.Retryable.resetBeforeRetry(Integer). Nothing calls this any more.
* It cannot be deleted: dropping a global member makes the package install fail in every
* subscriber org that referenced it. See docs/api-evolution.md.
**/
```

## Why ApexDoc is not enforced

`pmd/ruleset.xml` deliberately excludes `category/apex/documentation.xml`.
Requiring `@description` and `@param` on every member produces exactly the
restatement this policy exists to remove. Editor plugins ship that rule on by
default, so expect warnings; ignore them.

## Design

Ordinary clean-code expectations apply, and they matter more here than in an org
codebase because this is a library whose public surface is
[frozen once shipped](/docs/api-evolution.md):

- **KISS.** The smallest thing that solves the actual problem. No configuration
nobody asked for.
- **DRY, within reason.** Duplication in tests is often clearer than a shared
helper. Duplication in framework logic is a bug waiting to diverge.
- **SOLID.** Most relevant here is interface segregation: many small capability
interfaces beat one fat one, because Apex has no default methods, so a fat
interface can never gain a member.
- **Composition over inheritance.** A consumer has one inheritance slot. Do not
spend it. Prefer a marker interface the consumer can add to any class.
- **Guard clauses over nesting.** Early return, and let the shape of the method
show the flow.
29 changes: 29 additions & 0 deletions extras/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ namespace, which is exactly why they cannot ship inside the package.

Copy what you need. Rename anything to suit your project.

| File | Needed for |
| ---- | ---------- |
| `BaseQueueableJob`, `BaseChunkJob` | `deepClone()`, `restoreStateOnRetry()`, `restoreStateOnNextChunk()` |
| `AsyncJobSerializer` | `Async.requeue()` |

All of them exist for one reason: JSON cannot cross a namespace boundary, so the conversion has to
run in your namespace. See
[Installing as a Package](https://async.beyondthecloud.dev/introduction/packaged-install) for the
full checklist.

## `BaseQueueableJob`

Only needed when Async Lib is installed as a **namespaced package**. If you deployed the source
Expand Down Expand Up @@ -69,3 +79,22 @@ public class OddJob extends BaseQueueableJob {

See [Deep Clone in Packages](https://async.beyondthecloud.dev/explanations/deep-clone-in-packages)
for the full explanation and for the error messages that point back here.

## `AsyncJobSerializer`

Only needed when Async Lib is installed as a **namespaced package** and you use `Async.requeue()`.

Requeue stores a snapshot of a job on `AsyncResult__c` and rebuilds it later. Both the store and
the rebuild are JSON conversions, and JSON cannot cross a namespace boundary in either direction,
so both have to run in your code. This class is that code.

Register it once, on the `All` record of `QueueableJobSetting__mdt`:

```
JobSerializerClass__c = AsyncJobSerializer
```

It must stay `global`. Async Lib resolves it by name from inside its own namespace, and
`Type.forName` reaches nothing else across the boundary. The methods stay `public`.

See [Requeue](https://async.beyondthecloud.dev/explanations/requeue).
20 changes: 20 additions & 0 deletions extras/classes/AsyncJobSerializer.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Copy this when Async Lib is installed as a namespaced package and you use Async.requeue().
* Register it once: QueueableJobSetting__mdt.JobSerializerClass__c = 'AsyncJobSerializer' on the
* All record.
*
* JSON cannot cross a namespace boundary in either direction, so Async Lib can neither store nor
* rebuild your job from inside its own namespace. Both halves run here instead, in yours.
*
* `global` is required. Async Lib resolves this class by name, and Type.forName reaches nothing
* else across the boundary. The methods stay public.
**/
global class AsyncJobSerializer implements btcdev.Async.JobSerializer {
public String serialize(btcdev.QueueableJob job) {
return JSON.serialize(job);
}

public btcdev.QueueableJob deserialize(String className, String payload) {
return (btcdev.QueueableJob) JSON.deserialize(payload, Type.forName(className));
}
}
5 changes: 5 additions & 0 deletions extras/classes/AsyncJobSerializer.cls-meta.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>66.0</apiVersion>
<status>Active</status>
</ApexClass>
60 changes: 60 additions & 0 deletions force-app/main/default/classes/Async.cls
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ public inherited sharing class Async {
QueueableManager.get().skipJob(customJobId);
}

public static RequeueSummary requeue(Id resultId) {
return AsyncRequeue.run(new Set<Id>{ resultId });
}

public static RequeueSummary requeue(Set<Id> resultIds) {
return AsyncRequeue.run(resultIds);
}

// Inside a QueueableJob subclass the inherited `backoff` field shadows the Backoff type, so a
// bare `Backoff.exponential(1)` will not compile there. Reaching the factories through Async
// is collision-free in both packaged and source deployments.
Expand All @@ -67,6 +75,27 @@ public inherited sharing class Async {
}
}

public interface OnJobEnqueued {
void onJobEnqueued(JobContext ctx);
}

public interface OnJobSucceeded {
void onJobSucceeded(JobContext ctx);
}

public interface OnJobFailed {
void onJobFailed(FailureContext ctx);
}

public interface OnRetryEnqueued {
void onRetryEnqueued(FailureContext ctx);
}

public interface JobSerializer {
String serialize(QueueableJob job);
QueueableJob deserialize(String className, String payload);
}

public interface Retryable {
void resetBeforeRetry(Integer attempt);
}
Expand Down Expand Up @@ -171,6 +200,13 @@ public inherited sharing class Async {
}
}

@JsonAccess(serializable='always' deserializable='always')
public class RequeueSummary {
public List<Id> requeued = new List<Id>();
public Map<Id, String> skipReasonByResultId = new Map<Id, String>();
public Result enqueueResult;
}

public enum AsyncType {
QUEUEABLE,
BATCHABLE,
Expand All @@ -189,6 +225,27 @@ public inherited sharing class Async {
EXHAUSTED
}

@JsonAccess(serializable='always' deserializable='always')
public class JobContext {
public String customJobId;
public String className;
public Id salesforceJobId;
public String chainId;
public Integer priority;
public Integer retryAttempt;
public Map<String, String> info;

public JobContext(QueueableJob job) {
this.customJobId = job.customJobId;
this.className = job.className;
this.salesforceJobId = job.salesforceJobId;
this.chainId = job.chainId;
this.priority = job.priority;
this.retryAttempt = job.retryAttempt;
this.info = job.info ?? new Map<String, String>();
}
}

@JsonAccess(serializable='always' deserializable='always')
public class FailureContext {
public RetryOutcome retryOutcome;
Expand All @@ -198,6 +255,8 @@ public inherited sharing class Async {
public Integer retryAttempt;
public Integer maxRetries;
public String retryHistory;
public Integer nextAttemptDelayMinutes;
public Map<String, String> info;

public FailureContext(QueueableJob job) {
this.retryOutcome = retryOutcomeFor(job);
Expand All @@ -207,6 +266,7 @@ public inherited sharing class Async {
this.retryAttempt = job.retryAttempt;
this.maxRetries = job.maxRetries;
this.retryHistory = job.retryHistory;
this.info = job.info ?? new Map<String, String>();
}

private Async.RetryOutcome retryOutcomeFor(QueueableJob job) {
Expand Down
Loading
Loading