diff --git a/.forceignore b/.forceignore index 29af63a..984d211 100644 --- a/.forceignore +++ b/.forceignore @@ -1,3 +1,5 @@ **/jsconfig.json **/.eslintrc.json + +**/__tests__/** diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ae35bf3..b2b59d6 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -50,7 +50,7 @@ Closes # - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code -- [ ] I have commented my code where necessary +- [ ] Names explain my code, and any comment I added is one nothing else could carry ([code style](../dev/docs/code-style.md)) - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings or errors - [ ] I have added tests that prove my fix is effective or that my feature works diff --git a/.gitignore b/.gitignore index 61d4be0..a2c2704 100644 --- a/.gitignore +++ b/.gitignore @@ -68,8 +68,8 @@ website/.vitepress/dist/ scripts/agent/ # Consumer-app scratch state, not source -package-tests/consumer-app/.sf/ -package-tests/consumer-app/.sfdx/ +dev/package-tests/consumer-app/.sf/ +dev/package-tests/consumer-app/.sfdx/ # Announcement drafts - marketing copy, not a repo artifact release-notes/linkedin/ diff --git a/.prettierignore b/.prettierignore index 886b3e3..8f3b0d3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -22,10 +22,13 @@ sfdx-project.json config/ ticket/ website/ -package-tests/anonymous/ +dev/package-tests/anonymous/ release-notes/ package.json README.md CONTRIBUTING.md SECURITY.md CODE_OF_CONDUCT.md + +# Manual test jobs and screenshot scripts, not part of the package +dev/job-timeline-demo/ diff --git a/.vscode/settings.json b/.vscode/settings.json index f424a75..0846a20 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "apexPMD.rulesetPath": "pmd/ruleset.xml", + "apexPMD.rulesetPath": "dev/pmd/ruleset.xml", "apexPMD.runOnFileOpen": true, "apexPMD.runOnFileSave": true } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90b0c1f..4beefe2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ git checkout -b feature/my-awesome-feature ### 4. Make Your Changes -Make your changes in the appropriate files. Please follow the current coding style and conventions used in the codebase. +Make your changes in the appropriate files. Please follow the current coding style and conventions used in the codebase, and the [code style](dev/docs/code-style.md): class, method and variable names explain the code, and a comment is allowed only when it cannot be done otherwise. - For Salesforce metadata: follow Salesforce best practices - For LWC components: follow the patterns in existing components @@ -55,7 +55,7 @@ finding has to be resolved before merge. `npm run pmd` prints the same report wi failing. PMD needs Java 8+ on your PATH; `scripts/pmd.sh` downloads the pinned PMD release into `.pmd-dist/` on first run. -The ruleset lives in `pmd/ruleset.xml` and is kept identical to the shared Beyond The Cloud +The ruleset lives in `dev/pmd/ruleset.xml` and is kept identical to the shared Beyond The Cloud ruleset. Do not weaken it to silence a finding. Either fix the code, or add a narrow `@SuppressWarnings('PMD.RuleName')` at the class or method with a `PMD False Positives:` note saying why. Suppressions travel with the source, which matters because consumers vendor these @@ -100,14 +100,14 @@ Please include: Before submitting your PR, make sure you've: - [ ] Written clear and concise commit messages -- [ ] Followed the [code style](docs/code-style.md), in particular: code is self-explaining and comments are a last resort +- [ ] Followed the [code style](dev/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, 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 a consumer-perspective test in `dev/package-tests/consumer-app` for any new `global` surface +- [ ] Checked [API evolution rules](dev/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) diff --git a/README.md b/README.md index 20c7a59..5babf76 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,17 @@ 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). +## What's in this repo + +| Folder | For | What it is | +| --- | --- | --- | +| `force-app` | everyone | the library | +| `extras` | packaged installs | optional classes you copy into your org | +| `website` | everyone | the docs site | +| `release-notes` | everyone | what changed in each version | +| `scripts`, `config` | maintainers | release, PMD and header scripts, scratch org definition | +| `dev` | maintainers | tests, demos and rules for working on the library. You don't need it to use Async Lib | + ## Contributors diff --git a/dev/README.md b/dev/README.md new file mode 100644 index 0000000..39f83fd --- /dev/null +++ b/dev/README.md @@ -0,0 +1,14 @@ +# dev + +For people working on Async Lib. You don't need any of this to use it. + +| Folder | What it is | +| --- | --- | +| `verify` | `bash dev/verify/verify.sh `, from `static` up to `all`. Run it before a PR | +| `package-tests` | tests that use the library the way a subscriber does, through the namespace | +| `job-timeline-demo` | real jobs to test the Async Jobs card by hand and to retake the docs screenshots | +| `docs` | code style and the rules for changing a shipped `global` surface | +| `pmd` | the PMD ruleset | +| `jest-mocks` | mocks for LWC Jest tests | + +`verify` creates scratch orgs when they are missing. It uses the `target-dev-hub` from `sf config`, or `ASYNC_LIB_DEVHUB` if you set it. diff --git a/docs/api-evolution.md b/dev/docs/api-evolution.md similarity index 100% rename from docs/api-evolution.md rename to dev/docs/api-evolution.md diff --git a/docs/code-style.md b/dev/docs/code-style.md similarity index 85% rename from docs/code-style.md rename to dev/docs/code-style.md index 812b5eb..1fcf0ee 100644 --- a/docs/code-style.md +++ b/dev/docs/code-style.md @@ -2,7 +2,8 @@ ## Comments -**Code must be self-explaining. A comment is a last resort, not a default.** +**No comments, unless it cannot be done otherwise. Class, method and variable +names explain the code.** 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 @@ -39,7 +40,7 @@ String.valueOf((DateTime) job); 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`. +evolve**, it belongs in `dev/docs/api-evolution.md`. ### The two allowed exceptions @@ -63,13 +64,13 @@ otherwise remove it. /** * 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. + * subscriber org that referenced it. See dev/docs/api-evolution.md. **/ ``` ## Why ApexDoc is not enforced -`pmd/ruleset.xml` deliberately excludes `category/apex/documentation.xml`. +`dev/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. @@ -78,7 +79,7 @@ default, so expect warnings; ignore them. 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): +[frozen once shipped](api-evolution.md): - **KISS.** The smallest thing that solves the actual problem. No configuration nobody asked for. @@ -91,3 +92,7 @@ codebase because this is a library whose public surface is 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. +- **No inner class as an `@AuraEnabled` parameter.** Under a namespace the call + dies with `An internal server error has occurred` before the method runs, and + a non-namespaced org never shows it. Use a top-level class. PMD enforces this + as `AuraEnabledInnerClassParameter`. diff --git a/dev/jest-mocks/lightning/modal.js b/dev/jest-mocks/lightning/modal.js new file mode 100644 index 0000000..4eb3177 --- /dev/null +++ b/dev/jest-mocks/lightning/modal.js @@ -0,0 +1,8 @@ +import { LightningElement, api } from 'lwc'; + +export default class LightningModal extends LightningElement { + @api label; + @api size; + static open = jest.fn(() => Promise.resolve()); + close() {} +} diff --git a/dev/job-timeline-demo/README.md b/dev/job-timeline-demo/README.md new file mode 100644 index 0000000..48f5f59 --- /dev/null +++ b/dev/job-timeline-demo/README.md @@ -0,0 +1,29 @@ +# Job Timeline demo + +Real jobs for the Async Jobs card, to test it by hand and to retake the docs screenshots. Scratch orgs only: `seed` changes `QueueableJobSetting.All`, and `cleanup` deletes every `AsyncResult__c` row. + +Not part of the package. Needs `sf`, `jq`, and for screenshots a global `playwright` (`npm i -g playwright`). + +```bash +bash dev/job-timeline-demo/run.sh [org-alias] [chromium-path] +``` + +Org alias defaults to `async-lib-scratch`. + +| Action | What it does | Look at | +| --- | --- | --- | +| `seed` | deploys the demo jobs and settings, runs failed, retried, chunk, priority and 80 small chains. Settles in about 4 minutes | `Northwind Order #10248`, and `Fabrikam Order #20001` for more than 50 chains | +| `live` | a long chain with a runtime insert, a retry and a failure | `Northwind Order #10248` | +| `live-priority` | a job that adds a priority job and a plain job after 30 s, then a chunk run. Watch them arrive with `NEW`, in run order | `Northwind Order #10248` | +| `live-delay` | a chain with three delays and one failure, about 5 minutes. Shows `DELAYED`, `RUNNING`, `RUNNING \| DELAYED`, then `RUNNING \| ✕ 1 \| DELAYED`, and ends `FAILED` | `Northwind Order #10248` | +| `live-quick` | two plain jobs, about 90 seconds, to watch a chain go in and out of `In flight` | `Northwind Order #10248` | +| `showcase` | the runs in the docs screenshots, a minute apart. About 7 minutes | `Acme Order #00124` | +| `screenshots` | retakes all six images in `website/public/images/job-timeline`. Run `showcase` first | | +| `cleanup` | deletes every result row and the demo accounts | | +| `reset` | puts `QueueableJobSetting.All` back to the repo defaults. Do this before `verify.sh full` | | + +An idle card does not poll, so refresh it after starting a `live` action. It polls by itself from then on. + +Priority examples from `seed` are on `Northwind Order #10248`: `Priority set before start` shows the priority job first, in run order. Open the same card after `live-priority` has finished and the added jobs sit in place with no `NEW`. + +Changed the card? Add an example here if a new state has none, then run `showcase` and `screenshots`. diff --git a/dev/job-timeline-demo/force-app/main/default/classes/AccountRollupJob.cls b/dev/job-timeline-demo/force-app/main/default/classes/AccountRollupJob.cls new file mode 100644 index 0000000..961ead5 --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/classes/AccountRollupJob.cls @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public class AccountRollupJob extends OrderStepJob { +} diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsAsyncLogger.cls-meta.xml b/dev/job-timeline-demo/force-app/main/default/classes/AccountRollupJob.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsAsyncLogger.cls-meta.xml rename to dev/job-timeline-demo/force-app/main/default/classes/AccountRollupJob.cls-meta.xml diff --git a/dev/job-timeline-demo/force-app/main/default/classes/CommissionSplitJob.cls b/dev/job-timeline-demo/force-app/main/default/classes/CommissionSplitJob.cls new file mode 100644 index 0000000..23bfc11 --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/classes/CommissionSplitJob.cls @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public class CommissionSplitJob extends QueueableJob implements Async.Retryable { + public void resetBeforeRetry(Integer attempt) { + } + public override void work() { + throw new DmlException( + 'UNABLE_TO_LOCK_ROW: unable to obtain exclusive access to this record. ' + + 'The commission split was rolled back.' + ); + } +} diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsBaseChunkJob.cls-meta.xml b/dev/job-timeline-demo/force-app/main/default/classes/CommissionSplitJob.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsBaseChunkJob.cls-meta.xml rename to dev/job-timeline-demo/force-app/main/default/classes/CommissionSplitJob.cls-meta.xml diff --git a/dev/job-timeline-demo/force-app/main/default/classes/CreateInvoiceJob.cls b/dev/job-timeline-demo/force-app/main/default/classes/CreateInvoiceJob.cls new file mode 100644 index 0000000..2dc8d99 --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/classes/CreateInvoiceJob.cls @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public class CreateInvoiceJob extends OrderStepJob { + public override void work() { + super.work(); + Async.queueable(new WriteAuditTrail()).name('Write audit trail').attachFinalizer(); + } +} diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsBaseQueueableJob.cls-meta.xml b/dev/job-timeline-demo/force-app/main/default/classes/CreateInvoiceJob.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsBaseQueueableJob.cls-meta.xml rename to dev/job-timeline-demo/force-app/main/default/classes/CreateInvoiceJob.cls-meta.xml diff --git a/dev/job-timeline-demo/force-app/main/default/classes/OrderPagesJob.cls b/dev/job-timeline-demo/force-app/main/default/classes/OrderPagesJob.cls new file mode 100644 index 0000000..dab840e --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/classes/OrderPagesJob.cls @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public class OrderPagesJob extends ChunkJob implements Async.ChunkResettable { + public Integer spinMs = 2500; + public override void work(List page) { + Long until = System.currentTimeMillis() + spinMs; + while (System.currentTimeMillis() < until) { + } + } + public void resetBeforeNextChunk(Integer pageNumber) { + } +} diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsJobSerializer.cls-meta.xml b/dev/job-timeline-demo/force-app/main/default/classes/OrderPagesJob.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsJobSerializer.cls-meta.xml rename to dev/job-timeline-demo/force-app/main/default/classes/OrderPagesJob.cls-meta.xml diff --git a/dev/job-timeline-demo/force-app/main/default/classes/OrderStepJob.cls b/dev/job-timeline-demo/force-app/main/default/classes/OrderStepJob.cls new file mode 100644 index 0000000..dafbc1c --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/classes/OrderStepJob.cls @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public virtual class OrderStepJob extends QueueableJob { + public Integer spinMs = 1500; + public override virtual void work() { + Long until = System.currentTimeMillis() + spinMs; + while (System.currentTimeMillis() < until) { + } + } +} diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestChainControl.cls-meta.xml b/dev/job-timeline-demo/force-app/main/default/classes/OrderStepJob.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestChainControl.cls-meta.xml rename to dev/job-timeline-demo/force-app/main/default/classes/OrderStepJob.cls-meta.xml diff --git a/dev/job-timeline-demo/force-app/main/default/classes/PriorityDemoJob.cls b/dev/job-timeline-demo/force-app/main/default/classes/PriorityDemoJob.cls new file mode 100644 index 0000000..8d97c52 --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/classes/PriorityDemoJob.cls @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public class PriorityDemoJob extends QueueableJob { + public Integer spinMs = 1000; + public Id addsJobsLinkedTo; + public override void work() { + Long until = System.currentTimeMillis() + spinMs; + while (System.currentTimeMillis() < until) { + } + if (addsJobsLinkedTo == null) { + return; + } + Async.queueable(new PriorityDemoJob()) + .name('Urgent, added at runtime') + .priority(1) + .linkRecord(addsJobsLinkedTo) + .enqueue(); + Async.queueable(new PriorityDemoJob()) + .name('Plain, added at runtime') + .linkRecord(addsJobsLinkedTo) + .chain(); + } +} diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkCallouts.cls-meta.xml b/dev/job-timeline-demo/force-app/main/default/classes/PriorityDemoJob.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestChunkCallouts.cls-meta.xml rename to dev/job-timeline-demo/force-app/main/default/classes/PriorityDemoJob.cls-meta.xml diff --git a/dev/job-timeline-demo/force-app/main/default/classes/RepriceLinesJob.cls b/dev/job-timeline-demo/force-app/main/default/classes/RepriceLinesJob.cls new file mode 100644 index 0000000..aee474c --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/classes/RepriceLinesJob.cls @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public class RepriceLinesJob extends ChunkJob implements Async.ChunkResettable { + public override void work(List page) { + if (getRun().currentPageNumber() == 5) { + throw new DmlException('FIELD_CUSTOM_VALIDATION_EXCEPTION: Discount cannot exceed 40%'); + } + } + public void resetBeforeNextChunk(Integer pageNumber) { + } +} diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkJob.cls-meta.xml b/dev/job-timeline-demo/force-app/main/default/classes/RepriceLinesJob.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestChunkJob.cls-meta.xml rename to dev/job-timeline-demo/force-app/main/default/classes/RepriceLinesJob.cls-meta.xml diff --git a/dev/job-timeline-demo/force-app/main/default/classes/ReserveStockJob.cls b/dev/job-timeline-demo/force-app/main/default/classes/ReserveStockJob.cls new file mode 100644 index 0000000..c0fff24 --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/classes/ReserveStockJob.cls @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public class ReserveStockJob extends OrderStepJob { +} diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkRun.cls-meta.xml b/dev/job-timeline-demo/force-app/main/default/classes/ReserveStockJob.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestChunkRun.cls-meta.xml rename to dev/job-timeline-demo/force-app/main/default/classes/ReserveStockJob.cls-meta.xml diff --git a/dev/job-timeline-demo/force-app/main/default/classes/StopOnClosedPeriodJob.cls b/dev/job-timeline-demo/force-app/main/default/classes/StopOnClosedPeriodJob.cls new file mode 100644 index 0000000..b82e3b5 --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/classes/StopOnClosedPeriodJob.cls @@ -0,0 +1,11 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public class StopOnClosedPeriodJob extends OrderStepJob { + public override void work() { + Async.stopChain(); + } +} diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestFailureHook.cls-meta.xml b/dev/job-timeline-demo/force-app/main/default/classes/StopOnClosedPeriodJob.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestFailureHook.cls-meta.xml rename to dev/job-timeline-demo/force-app/main/default/classes/StopOnClosedPeriodJob.cls-meta.xml diff --git a/dev/job-timeline-demo/force-app/main/default/classes/SyncToErpJob.cls b/dev/job-timeline-demo/force-app/main/default/classes/SyncToErpJob.cls new file mode 100644 index 0000000..66130bf --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/classes/SyncToErpJob.cls @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public class SyncToErpJob extends OrderStepJob { +} diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestLogging.cls-meta.xml b/dev/job-timeline-demo/force-app/main/default/classes/SyncToErpJob.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestLogging.cls-meta.xml rename to dev/job-timeline-demo/force-app/main/default/classes/SyncToErpJob.cls-meta.xml diff --git a/dev/job-timeline-demo/force-app/main/default/classes/TimelineDemoJobs.cls b/dev/job-timeline-demo/force-app/main/default/classes/TimelineDemoJobs.cls new file mode 100644 index 0000000..fc1edca --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/classes/TimelineDemoJobs.cls @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public class TimelineDemoJobs { + public class DemoException extends Exception { + } + + public class StepJob extends QueueableJob { + public Integer index; + public Integer spinMs = 300; + public StepJob(Integer index) { + this.index = index; + } + public override void work() { + Long until = System.currentTimeMillis() + spinMs; + while (System.currentTimeMillis() < until) { + } + } + } + + public class FailingJob extends QueueableJob implements Async.Retryable { + public String message; + public FailingJob(String message) { + this.message = message; + } + public void resetBeforeRetry(Integer attempt) { + } + public override void work() { + throw new DemoException(message); + } + } + + public class FlakyJob extends QueueableJob implements Async.Retryable { + public Integer succeedsOnAttempt; + public FlakyJob(Integer succeedsOnAttempt) { + this.succeedsOnAttempt = succeedsOnAttempt; + } + public void resetBeforeRetry(Integer attempt) { + } + public override void work() { + if (retryAttempt < succeedsOnAttempt) { + throw new DemoException('Timed out waiting for the pricing service (attempt ' + (retryAttempt + 1) + ')'); + } + } + } + + public class LinkingJob extends QueueableJob { + public Id accountId; + public LinkingJob(Id accountId) { + this.accountId = accountId; + } + public override void work() { + Async.linkRecord(accountId); + Async.queueable(new StepJob(99)).name('Inserted at runtime').linkRecord(accountId).nextInChain().enqueue(); + Async.queueable(new AuditFinalizer()).attachFinalizer(); + } + } + + public class AuditFinalizer extends QueueableJob.Finalizer { + public override void work() { + } + } + + public class RelatedLinkingJob extends QueueableJob { + public Id accountId; + public RelatedLinkingJob(Id accountId) { + this.accountId = accountId; + } + public override void work() { + Async.linkRecords([SELECT Id FROM Contact WHERE AccountId = :accountId]); + Async.linkRecords([SELECT Id FROM Opportunity WHERE AccountId = :accountId]); + } + } + + public class StopChainJob extends QueueableJob { + public override void work() { + Async.stopChain(); + } + } + + public class SkipperJob extends QueueableJob { + public String targetCustomJobId; + public override void work() { + Async.skipJob(targetCustomJobId); + } + } + + public class PageJob extends ChunkJob implements Async.ChunkResettable { + public override void work(List page) { + Async.linkRecords(page); + if (getRun().currentPageNumber() == 7) { + throw new DemoException('UNABLE_TO_LOCK_ROW on page 7'); + } + } + public void resetBeforeNextChunk(Integer pageNumber) { + } + } + + public class FragilePageJob extends ChunkJob implements Async.ChunkResettable { + public override void work(List page) { + if (getRun().currentPageNumber() == 3) { + throw new DemoException('Validation failed: missing price on page 3'); + } + } + public void resetBeforeNextChunk(Integer pageNumber) { + } + } +} diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestRequeue.cls-meta.xml b/dev/job-timeline-demo/force-app/main/default/classes/TimelineDemoJobs.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestRequeue.cls-meta.xml rename to dev/job-timeline-demo/force-app/main/default/classes/TimelineDemoJobs.cls-meta.xml diff --git a/dev/job-timeline-demo/force-app/main/default/classes/WriteAuditTrail.cls b/dev/job-timeline-demo/force-app/main/default/classes/WriteAuditTrail.cls new file mode 100644 index 0000000..339a1b1 --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/classes/WriteAuditTrail.cls @@ -0,0 +1,10 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public class WriteAuditTrail extends QueueableJob.Finalizer { + public override void work() { + } +} diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestRetryBehavior.cls-meta.xml b/dev/job-timeline-demo/force-app/main/default/classes/WriteAuditTrail.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestRetryBehavior.cls-meta.xml rename to dev/job-timeline-demo/force-app/main/default/classes/WriteAuditTrail.cls-meta.xml diff --git a/dev/job-timeline-demo/force-app/main/default/customMetadata/QueueableJobSetting.All.md-meta.xml b/dev/job-timeline-demo/force-app/main/default/customMetadata/QueueableJobSetting.All.md-meta.xml new file mode 100644 index 0000000..0305968 --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/customMetadata/QueueableJobSetting.All.md-meta.xml @@ -0,0 +1,25 @@ + + + + false + + IsDisabled__c + false + + + QueueableJobName__c + All + + + CreateResult__c + true + + + TrackJobProgress__c + Yes + + + StoreJobPayload__c + Yes + + diff --git a/dev/job-timeline-demo/force-app/main/default/settings/Deployment.settings-meta.xml b/dev/job-timeline-demo/force-app/main/default/settings/Deployment.settings-meta.xml new file mode 100644 index 0000000..a0a1594 --- /dev/null +++ b/dev/job-timeline-demo/force-app/main/default/settings/Deployment.settings-meta.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/dev/job-timeline-demo/reset/force-app/main/default/customMetadata/QueueableJobSetting.All.md-meta.xml b/dev/job-timeline-demo/reset/force-app/main/default/customMetadata/QueueableJobSetting.All.md-meta.xml new file mode 100644 index 0000000..1a5dd6f --- /dev/null +++ b/dev/job-timeline-demo/reset/force-app/main/default/customMetadata/QueueableJobSetting.All.md-meta.xml @@ -0,0 +1,29 @@ + + + + false + + IsDisabled__c + false + + + QueueableJobName__c + All + + + CreateResult__c + false + + + TrackJobProgress__c + + + + StoreJobPayload__c + + + + MaxRecordLinks__c + + + diff --git a/dev/job-timeline-demo/reset/sfdx-project.json b/dev/job-timeline-demo/reset/sfdx-project.json new file mode 100644 index 0000000..c683e6f --- /dev/null +++ b/dev/job-timeline-demo/reset/sfdx-project.json @@ -0,0 +1,4 @@ +{ + "packageDirectories": [{ "path": "force-app", "default": true }], + "sourceApiVersion": "66.0" +} diff --git a/dev/job-timeline-demo/run.sh b/dev/job-timeline-demo/run.sh new file mode 100755 index 0000000..3e852f6 --- /dev/null +++ b/dev/job-timeline-demo/run.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail +ACTION=${1:-} +ORG=${2:-async-lib-scratch} +CHROMIUM=${3:-} +DEMO_DIR=$(cd "$(dirname "$0")" && pwd) +REPO_ROOT=$(cd "$DEMO_DIR/../.." && pwd) +SHOWCASE_RECORD='Acme Order #00124' +SEED_RECORD='Northwind Order #10248' +MANY_CHAINS_RECORD='Fabrikam Order #20001' +SHOWCASE_SCRIPTS=(00-account 01-stock 02-export 03-close 04-reprice 05-sync 06-fulfilment) + +usage() { + echo "Usage: bash dev/job-timeline-demo/run.sh [org-alias] [chromium-path]" >&2 + exit 1 +} + +run_apex() { + sf apex run --target-org "$ORG" --file "$DEMO_DIR/scripts/$1" > /dev/null +} + +deploy_demo() { + (cd "$DEMO_DIR" && sf project deploy start --target-org "$ORG" --source-dir force-app/main/default/settings --wait 10 --ignore-conflicts > /dev/null) + (cd "$DEMO_DIR" && sf project deploy start --target-org "$ORG" --source-dir force-app --wait 10 --ignore-conflicts > /dev/null) + sf org assign permset --target-org "$ORG" --name AsyncResultAccess --name AsyncRequeueAccess > /dev/null 2>&1 || true +} + +record_path() { + local id + id=$(sf data query -q "SELECT Id FROM Account WHERE Name = '$1' ORDER BY CreatedDate DESC LIMIT 1" --target-org "$ORG" --json | jq -r '.result.records[0].Id') + echo "/lightning/r/Account/$id/view" +} + +record_url() { + echo "$(sf org display --target-org "$ORG" --json | jq -r '.result.instanceUrl')$(record_path "$1")" +} + +running_user_id() { + local username + username=$(sf org display --target-org "$ORG" --json | jq -r '.result.username') + sf data query -q "SELECT Id FROM User WHERE Username = '$username'" --target-org "$ORG" --json | jq -r '.result.records[0].Id' +} + +set_user_locale() { + sf data update record --sobject User --record-id "$1" --values "LocaleSidKey=$2" --target-org "$ORG" > /dev/null +} + +take_shots() { + local stage=$1 login_file + login_file=$(mktemp) + sf org open --target-org "$ORG" --url-only --path "$(record_path "$SHOWCASE_RECORD")" --json > "$login_file" 2> /dev/null + NODE_PATH=$(npm root -g) node "$DEMO_DIR/screenshots/take.js" "$login_file" "$stage" "$REPO_ROOT/website/public/images/job-timeline" "$CHROMIUM" || { + rm -f "$login_file" + exit 1 + } + rm -f "$login_file" +} + +case "$ACTION" in + seed) + deploy_demo + run_apex 01-seed.apex + run_apex 04-many-chains.apex + run_apex 05-priority.apex + echo "Seeded. Settles in about 4 minutes." + echo "Record page: $(record_url "$SEED_RECORD")" + echo "More than 50 chains: $(record_url "$MANY_CHAINS_RECORD")" + ;; + live) + run_apex 02-live.apex + echo "Live chain enqueued. Refresh the card: $(record_url "$SEED_RECORD")" + ;; + live-priority) + run_apex 06-priority-live.apex + echo "Chain enqueued. Refresh the card, jobs arrive after 30 s: $(record_url "$SEED_RECORD")" + ;; + live-delay) + run_apex 07-delay-live.apex + echo "Chain enqueued. Refresh the card, it takes about 5 minutes: $(record_url "$SEED_RECORD")" + ;; + live-quick) + run_apex 08-quick-live.apex + echo "Short chain enqueued. Refresh the card, it takes about 90 seconds: $(record_url "$SEED_RECORD")" + ;; + showcase) + deploy_demo + for script in "${SHOWCASE_SCRIPTS[@]}"; do + run_apex "showcase/$script.apex" + [ "$script" = "00-account" ] || [ "$script" = "06-fulfilment" ] || sleep 60 + done + sleep 120 + echo "Showcase settled: $(record_url "$SHOWCASE_RECORD")" + ;; + screenshots) + user_id=$(running_user_id) + user_locale=$(sf data query -q "SELECT LocaleSidKey FROM User WHERE Id = '$user_id'" --target-org "$ORG" --json | jq -r '.result.records[0].LocaleSidKey') + trap 'set_user_locale "$user_id" "$user_locale"' EXIT + set_user_locale "$user_id" en_GB + take_shots settled + take_shots map + run_apex showcase/07-live.apex + take_shots running + ;; + cleanup) + run_apex 03-cleanup.apex + echo "Cleaned." + ;; + reset) + (cd "$DEMO_DIR/reset" && sf project deploy start --target-org "$ORG" --source-dir force-app --wait 10 --ignore-conflicts > /dev/null) + echo "QueueableJobSetting.All is back to the repo defaults." + ;; + *) + usage + ;; +esac diff --git a/dev/job-timeline-demo/screenshots/take.js b/dev/job-timeline-demo/screenshots/take.js new file mode 100644 index 0000000..6371571 --- /dev/null +++ b/dev/job-timeline-demo/screenshots/take.js @@ -0,0 +1,109 @@ +const fs = require('fs'); +const { chromium } = require('playwright'); + +const [loginFile, stage, outDir, chromiumPath] = process.argv.slice(2); +const loginUrl = JSON.parse(fs.readFileSync(loginFile, 'utf8')).result.url; +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +const openCard = async (browser, viewport, deviceScaleFactor) => { + const context = await browser.newContext({ viewport, deviceScaleFactor }); + const page = await context.newPage(); + await page.goto(loginUrl, { waitUntil: 'load', timeout: 120000 }); + await page.waitForSelector('c-async-job-timeline', { timeout: 120000 }); + await sleep(8000); + return page; +}; + +const shotOf = page => async (locator, name) => { + await locator.scrollIntoViewIfNeeded(); + await sleep(300); + const box = await locator.boundingBox(); + const scrollY = await page.evaluate(() => window.scrollY); + await page.screenshot({ + path: `${outDir}/${name}.png`, + fullPage: true, + clip: { x: Math.max(0, box.x - 10), y: box.y + scrollY - 10, width: box.width + 20, height: box.height + 20 } + }); + console.log(name); +}; + +const chainNamed = (page, card, name) => + card + .locator('c-async-job-sequence') + .filter({ has: page.locator('.chain-head [data-id="name"]', { hasText: name }) }) + .first(); + +const showAllRows = card => + card.locator('.scroller').evaluate(el => { + el.style.height = 'auto'; + el.style.overflow = 'visible'; + }); + +const settled = async browser => { + const page = await openCard(browser, { width: 1440, height: 1800 }, 2); + const shot = shotOf(page); + const card = page.locator('c-async-job-timeline').first(); + await showAllRows(card); + await sleep(300); + await shot(card, 'list'); + const fulfilment = chainNamed(page, card, 'Order fulfilment pipeline'); + await fulfilment.locator('.chain-head').click(); + await sleep(600); + const failedStep = fulfilment.locator('c-async-job-step', { hasText: 'Commission split' }).first(); + await failedStep.locator('[data-id="row"] .name').click(); + await sleep(600); + await shot(fulfilment, 'failed-chain'); + await failedStep.locator('[data-id="row"] .name').click(); + await fulfilment.locator('.chain-head').click(); + await sleep(400); + const reprice = chainNamed(page, card, 'Reprice order lines'); + await reprice.locator('.chain-head').click(); + await sleep(500); + await reprice.locator('c-async-job-step').first().locator('[data-id="row"] .name').click(); + await sleep(500); + await shot(reprice, 'failed-chunk'); +}; + +const map = async browser => { + const page = await openCard(browser, { width: 2000, height: 1300 }, 1.5); + const card = page.locator('c-async-job-timeline').first(); + const fulfilment = chainNamed(page, card, 'Order fulfilment pipeline'); + await fulfilment.locator('.chain-actions button').last().click(); + await sleep(500); + await fulfilment.locator('lightning-menu-item', { hasText: 'Chain map' }).locator('visible=true').first().click(); + await sleep(3000); + const header = await page.locator('.slds-modal__header').locator('visible=true').first().boundingBox(); + const footer = await page.locator('.slds-modal__footer').locator('visible=true').first().boundingBox(); + await page.screenshot({ + path: `${outDir}/chain-map.png`, + clip: { x: header.x, y: header.y, width: header.width, height: footer.y + footer.height - header.y } + }); + console.log('chain-map'); +}; + +const running = async browser => { + const page = await openCard(browser, { width: 1440, height: 1800 }, 2); + const shot = shotOf(page); + const cards = page.locator('c-async-job-timeline'); + await showAllRows(cards.nth(0)); + await showAllRows(cards.nth(1)); + const chunkStep = cards.nth(0).locator('c-async-job-step', { hasText: 'Tax 16 lines' }).first(); + for (let waited = 0; waited < 120; waited++) { + const summary = await chunkStep.locator('[data-id="strip-summary"]').textContent({ timeout: 1000 }).catch(() => ''); + if (/page [2-6] of 8/.test(summary)) break; + await sleep(1000); + } + await shot(cards.nth(0), 'running'); + await shot(cards.nth(1), 'sidebar'); +}; + +const stages = { settled, map, running }; + +(async () => { + const browser = await chromium.launch({ headless: true, executablePath: chromiumPath || undefined }); + await stages[stage](browser); + await browser.close(); +})().catch(error => { + console.error('FAILED', error.message.split('\n')[0]); + process.exit(1); +}); diff --git a/dev/job-timeline-demo/scripts/01-seed.apex b/dev/job-timeline-demo/scripts/01-seed.apex new file mode 100644 index 0000000..acf3cec --- /dev/null +++ b/dev/job-timeline-demo/scripts/01-seed.apex @@ -0,0 +1,138 @@ +List orders = [SELECT Id FROM Account WHERE Name = 'Northwind Order #10248' ORDER BY CreatedDate DESC LIMIT 1]; +List others = [SELECT Id FROM Account WHERE Name = 'Contoso Order #10301' ORDER BY CreatedDate DESC LIMIT 1]; +Boolean firstSeed = orders.isEmpty(); +Account order = firstSeed ? new Account(Name = 'Northwind Order #10248') : orders[0]; +Account other = others.isEmpty() ? new Account(Name = 'Contoso Order #10301') : others[0]; +List newAccounts = new List(); +if (order.Id == null) newAccounts.add(order); +if (other.Id == null) newAccounts.add(other); +insert newAccounts; +List contacts = [SELECT Id FROM Contact WHERE AccountId = :order.Id]; +List opps = [SELECT Id FROM Opportunity WHERE AccountId = :order.Id]; +if (firstSeed) { + contacts = new List{ + new Contact(LastName = 'Fuller', FirstName = 'Andrew', AccountId = order.Id), + new Contact(LastName = 'Leverling', FirstName = 'Janet', AccountId = order.Id), + new Contact(LastName = 'Peacock', FirstName = 'Margaret', AccountId = order.Id) + }; + insert contacts; + opps = new List{ + new Opportunity(Name = 'Northwind renewal', AccountId = order.Id, StageName = 'Prospecting', CloseDate = Date.today().addDays(30)), + new Opportunity(Name = 'Northwind upsell', AccountId = order.Id, StageName = 'Prospecting', CloseDate = Date.today().addDays(60)) + }; + insert opps; +} +List lines = [SELECT Id FROM Account WHERE Name LIKE 'Line %' ORDER BY CreatedDate, Name LIMIT 600]; +if (lines.size() < 600) { + List missingLines = new List(); + for (Integer i = lines.size(); i < 600; i++) { + missingLines.add(new Account(Name = 'Line ' + i)); + } + insert missingLines; + lines.addAll(missingLines); +} +List firstLines = new List(); +for (Integer i = 0; i < 25; i++) { + firstLines.add(lines[i]); +} + +Async.Result reserve = Async.queueable(new TimelineDemoJobs.StepJob(1)) + .name('Reserve stock').chainName('Order fulfilment pipeline') + .linkRecord(order).linkRecords(new List{ lines[0], lines[1], lines[2], lines[3], lines[4] }) + .chain(); +Async.queueable(new TimelineDemoJobs.StepJob(2)).name('Create invoice').linkRecord(order).priority(1).chain(); +Async.Result commission = Async.queueable(new TimelineDemoJobs.FailingJob( + 'UNABLE_TO_LOCK_ROW: record 001XX000003GXYZ is currently being modified by another user. ' + + 'The commission split could not be written and was rolled back.')) + .name('Commission split').linkRecord(order) + .retry(2).backoff(Async.Backoff.fixed(1)).continueOnJobExecuteFail() + .chain(); +Async.Result rollups = Async.queueable(new TimelineDemoJobs.StepJob(4)).name('Recalculate account rollups').linkRecord(order) + .dependsOn(Async.after(commission).succeeded()).chain(); +Async.queueable(new TimelineDemoJobs.StepJob(5)).name('Sync to ERP').linkRecord(order) + .dependsOn(Async.after(rollups).succeeded()).chain(); +Async.queueable(new TimelineDemoJobs.StepJob(6)).name('Open support case').linkRecord(order) + .dependsOn(Async.after(commission).failed()).chain(); +Async.queueable(new TimelineDemoJobs.StepJob(7)).name('Archive order').linkRecord(order) + .dependsOn(Async.after(reserve).succeeded()).chain(); +Async.queueable(new TimelineDemoJobs.StepJob(8)).name('Notify sales team').linkRecord(order).chain(); +Async.queueable().enqueue(); + +QueueableBuilder longChain = Async.queueable(new TimelineDemoJobs.StepJob(1)) + .name('Step 1').chainName('Nightly price refresh (60 steps)').linkRecord(order); +for (Integer i = 2; i <= 60; i++) { + QueueableJob step = new TimelineDemoJobs.StepJob(i); + if (i == 10) { + step = new TimelineDemoJobs.FlakyJob(1); + } else if (i == 30) { + step = new TimelineDemoJobs.LinkingJob(other.Id); + } + longChain = longChain.chain(step).name('Step ' + i).linkRecord(order); + if (i == 10) { + longChain = longChain.retry(1).backoff(Async.Backoff.fixed(1)); + } +} +longChain.enqueue(); + +Async.chunk(new TimelineDemoJobs.PageJob(), ChunkSource.of(lines)).chunkSize(2) + .name('Reprice lines').chainName('Reprice 600 lines').linkRecord(order).enqueue(); + +Async.chunk(new TimelineDemoJobs.FragilePageJob(), ChunkSource.of(firstLines)).chunkSize(3) + .stopRemainingChunksOnFailure() + .name('Validate lines').chainName('Validate 25 lines').linkRecord(order).enqueue(); + +Async.queueable(new TimelineDemoJobs.StepJob(1)).name('Close periods').chainName('Month-end close').linkRecord(order) + .chain(new TimelineDemoJobs.StopChainJob()).name('Check ledger balance').linkRecord(order) + .chain(new TimelineDemoJobs.StepJob(3)).name('Post journals').linkRecord(order) + .chain(new TimelineDemoJobs.StepJob(4)).name('Lock books').linkRecord(order) + .enqueue(); + +Async.Result plan = Async.queueable(new TimelineDemoJobs.SkipperJob()).name('Plan hygiene run').chainName('Data hygiene').linkRecord(order).chain(); +Async.queueable(new TimelineDemoJobs.StepJob(2)).name('Dedupe contacts').linkRecord(order).chain(); +Async.Result purge = Async.queueable(new TimelineDemoJobs.StepJob(3)).name('Purge stale leads').linkRecord(order).chain(); +Async.queueable(new TimelineDemoJobs.StepJob(4)).name('Hygiene report').linkRecord(order).chain(); +((TimelineDemoJobs.SkipperJob) plan.job).targetCustomJobId = purge.job.customJobId; +Async.queueable().enqueue(); + +List many = new List{ order.Id }; +for (Contact c : contacts) { many.add(c.Id); } +for (Opportunity o : opps) { many.add(o.Id); } +for (Account line : firstLines) { many.add(line.Id); } +Async.queueable(new TimelineDemoJobs.RelatedLinkingJob(order.Id)) + .name('Account 360 refresh').chainName('Account 360').linkRecords(many).enqueue(); + +Async.queueable(new TimelineDemoJobs.StepJob(1)).linkRecord(order) + .chain(new TimelineDemoJobs.StepJob(2)).linkRecord(order).enqueue(); + +Async.queueable(new TimelineDemoJobs.StepJob(1)).name('Export orders').chainName('Delayed export') + .linkRecord(order).delay(5) + .chain(new TimelineDemoJobs.StepJob(2)).name('Upload to SFTP').linkRecord(order) + .enqueue(); + +for (Integer c = 1; c <= 12; c++) { + Async.queueable(new TimelineDemoJobs.StepJob(1)).name('Load price book').chainName('Price check ' + c).linkRecord(order) + .chain(new TimelineDemoJobs.StepJob(2)).name('Compare').linkRecord(order) + .chain(new TimelineDemoJobs.StepJob(3)).name('Report').linkRecord(order) + .enqueue(); +} +System.debug('SEEDED ' + order.Id); + +List twentyLines = new List(); +for (Integer i = 0; i < 20; i++) { twentyLines.add(lines[i]); } +List twelveLines = new List(); +for (Integer i = 20; i < 32; i++) { twelveLines.add(lines[i]); } +TimelineDemoJobs.StepJob mixedPrepare = new TimelineDemoJobs.StepJob(1); +mixedPrepare.spinMs = 3000; +TimelineDemoJobs.StepJob mixedReconcile = new TimelineDemoJobs.StepJob(2); +mixedReconcile.spinMs = 3000; +TimelineDemoJobs.StepJob mixedPublish = new TimelineDemoJobs.StepJob(3); +mixedPublish.spinMs = 3000; +Async.queueable(mixedPrepare).name('Prepare order').chainName('Order sync (mixed)').linkRecord(order) + .chunk(new TimelineDemoJobs.PageJob(), ChunkSource.of(twentyLines)).chunkSize(2) + .name('Reprice 20 lines').linkRecord(order) + .chain(mixedReconcile).name('Reconcile totals').linkRecord(order) + .chunk(new TimelineDemoJobs.PageJob(), ChunkSource.of(twelveLines)).chunkSize(2) + .name('Tax 12 lines').linkRecord(order) + .chain(mixedPublish).name('Publish order').linkRecord(order) + .chain(new TimelineDemoJobs.StepJob(4)).name('Notify owner').linkRecord(order) + .enqueue(); diff --git a/dev/job-timeline-demo/scripts/02-live.apex b/dev/job-timeline-demo/scripts/02-live.apex new file mode 100644 index 0000000..0450f9d --- /dev/null +++ b/dev/job-timeline-demo/scripts/02-live.apex @@ -0,0 +1,31 @@ +Account order = [SELECT Id FROM Account WHERE Name = 'Northwind Order #10248' ORDER BY CreatedDate DESC LIMIT 1]; +Account other = [SELECT Id FROM Account WHERE Name = 'Contoso Order #10301' ORDER BY CreatedDate DESC LIMIT 1]; + +QueueableBuilder live = Async.queueable(new TimelineDemoJobs.StepJob(1)) + .name('Validate order').chainName('Live demo pipeline').linkRecord(order); +for (Integer i = 2; i <= 10; i++) { + QueueableJob step; + if (i == 4) { + step = new TimelineDemoJobs.LinkingJob(other.Id); + } else if (i == 7) { + step = new TimelineDemoJobs.FlakyJob(1); + } else if (i == 9) { + step = new TimelineDemoJobs.FailingJob('Payment gateway returned 502 Bad Gateway'); + } else { + TimelineDemoJobs.StepJob slow = new TimelineDemoJobs.StepJob(i); + slow.spinMs = 4000; + step = slow; + } + live = live.chain(step).name('Live step ' + i).linkRecord(order); + if (i == 7) { + live = live.retry(1).backoff(Async.Backoff.fixed(1)); + } + if (i == 9) { + live = live.retry(1).backoff(Async.Backoff.fixed(1)).continueOnJobExecuteFail(); + } + if (i == 10) { + live = live.dependsOn(Async.afterPrevious().succeeded()); + } +} +live.enqueue(); +System.debug('LIVE SEEDED'); diff --git a/dev/job-timeline-demo/scripts/03-cleanup.apex b/dev/job-timeline-demo/scripts/03-cleanup.apex new file mode 100644 index 0000000..99f032f --- /dev/null +++ b/dev/job-timeline-demo/scripts/03-cleanup.apex @@ -0,0 +1,6 @@ +delete [SELECT Id FROM AsyncResult__c]; +delete [ + SELECT Id FROM Account + WHERE Name LIKE 'Northwind Order%' OR Name LIKE 'Contoso Order%' OR Name LIKE 'Fabrikam Order%' OR Name LIKE 'Acme Order%' OR Name LIKE 'Line %' +]; +System.debug('CLEANED'); diff --git a/dev/job-timeline-demo/scripts/04-many-chains.apex b/dev/job-timeline-demo/scripts/04-many-chains.apex new file mode 100644 index 0000000..cd5a4e8 --- /dev/null +++ b/dev/job-timeline-demo/scripts/04-many-chains.apex @@ -0,0 +1,42 @@ +if (![SELECT Id FROM AsyncResult__c WHERE ChainId__c = 'fabrikam-1' LIMIT 1].isEmpty()) { + return; +} +List existing = [SELECT Id FROM Account WHERE Name = 'Fabrikam Order #20001' LIMIT 1]; +Account order = existing.isEmpty() ? new Account(Name = 'Fabrikam Order #20001') : existing[0]; +upsert order; +List rows = new List(); +for (Integer chain = 1; chain <= 80; chain++) { + Boolean fails = Math.mod(chain, 5) == 0; + for (Integer step = 1; step <= 12; step++) { + Boolean failedStep = fails && step == 12; + rows.add( + new AsyncResult__c( + ChainId__c = 'fabrikam-' + chain, + ChainName__c = 'Fabrikam sync ' + chain, + ChainSequence__c = step, + CustomJobId__c = 'fabrikam-' + chain + '-' + step, + JobName__c = 'Step ' + step, + ClassName__c = 'TimelineDemoJobs.StepJob', + Status__c = failedStep ? 'FAILED' : 'COMPLETED', + ExceptionType__c = failedStep ? 'TimelineDemoJobs.DemoException' : null, + ExceptionMessage__c = failedStep ? 'Seeded failure on the last step' : null, + StartedAt__c = System.now().addMinutes(-chain), + FinishedAt__c = System.now().addMinutes(-chain).addSeconds(step) + ) + ); + } +} +insert rows; +List links = new List(); +for (AsyncResult__c row : rows) { + links.add( + new AsyncResultLink__c( + AsyncResult__c = row.Id, + Key__c = order.Id, + Source__c = 'Salesforce', + ObjectName__c = 'Account' + ) + ); +} +insert links; +System.debug('SEEDED MANY CHAINS'); diff --git a/dev/job-timeline-demo/scripts/05-priority.apex b/dev/job-timeline-demo/scripts/05-priority.apex new file mode 100644 index 0000000..2e12010 --- /dev/null +++ b/dev/job-timeline-demo/scripts/05-priority.apex @@ -0,0 +1,4 @@ +Account order = [SELECT Id FROM Account WHERE Name = 'Northwind Order #10248' ORDER BY CreatedDate DESC LIMIT 1]; +Async.queueable(new PriorityDemoJob()).name('Chained first').chainName('Priority set before start').linkRecord(order).chain(); +Async.queueable(new PriorityDemoJob()).name('Chained second').linkRecord(order).chain(); +Async.queueable(new PriorityDemoJob()).name('Chained last, priority 1').priority(1).linkRecord(order).enqueue(); diff --git a/dev/job-timeline-demo/scripts/06-priority-live.apex b/dev/job-timeline-demo/scripts/06-priority-live.apex new file mode 100644 index 0000000..ee6ec41 --- /dev/null +++ b/dev/job-timeline-demo/scripts/06-priority-live.apex @@ -0,0 +1,11 @@ +Account order = [SELECT Id FROM Account WHERE Name = 'Northwind Order #10248' ORDER BY CreatedDate DESC LIMIT 1]; +List lines = [SELECT Id FROM Account WHERE Name LIKE 'Line %' ORDER BY Name LIMIT 16]; +PriorityDemoJob first = new PriorityDemoJob(); +first.spinMs = 30000; +first.addsJobsLinkedTo = order.Id; +PriorityDemoJob last = new PriorityDemoJob(); +last.spinMs = 8000; +Async.queueable(first).name('Adds two jobs after 30 s').chainName('Jobs added while you watch').linkRecord(order) + .chunk(new OrderPagesJob(), ChunkSource.of(lines)).chunkSize(2).name('Tax 16 lines').linkRecord(order) + .chain(last).name('Sync to ERP').linkRecord(order) + .enqueue(); diff --git a/dev/job-timeline-demo/scripts/07-delay-live.apex b/dev/job-timeline-demo/scripts/07-delay-live.apex new file mode 100644 index 0000000..23a4f5d --- /dev/null +++ b/dev/job-timeline-demo/scripts/07-delay-live.apex @@ -0,0 +1,12 @@ +Account order = [SELECT Id FROM Account WHERE Name = 'Northwind Order #10248' ORDER BY CreatedDate DESC LIMIT 1]; +OrderStepJob reserve = new OrderStepJob(); +OrderStepJob invoice = new OrderStepJob(); +invoice.spinMs = 20000; +OrderStepJob rollups = new OrderStepJob(); +OrderStepJob sync = new OrderStepJob(); +Async.queueable(reserve).name('Reserve stock').chainName('Delays while you watch').linkRecord(order).delay(1) + .chain(invoice).name('Create invoice').linkRecord(order) + .chain(rollups).name('Recalculate rollups').linkRecord(order).delay(1) + .chain(new TimelineDemoJobs.FailingJob('Payment gateway returned 502 Bad Gateway')).name('Commission split').linkRecord(order).continueOnJobExecuteFail() + .chain(sync).name('Sync to ERP').linkRecord(order).delay(2) + .enqueue(); diff --git a/dev/job-timeline-demo/scripts/08-quick-live.apex b/dev/job-timeline-demo/scripts/08-quick-live.apex new file mode 100644 index 0000000..2b3e620 --- /dev/null +++ b/dev/job-timeline-demo/scripts/08-quick-live.apex @@ -0,0 +1,8 @@ +Account order = [SELECT Id FROM Account WHERE Name = 'Northwind Order #10248' ORDER BY CreatedDate DESC LIMIT 1]; +OrderStepJob first = new OrderStepJob(); +first.spinMs = 45000; +OrderStepJob second = new OrderStepJob(); +second.spinMs = 45000; +Async.queueable(first).name('First step').chainName('Quick in flight check').linkRecord(order) + .chain(second).name('Second step').linkRecord(order) + .enqueue(); diff --git a/dev/job-timeline-demo/scripts/showcase/00-account.apex b/dev/job-timeline-demo/scripts/showcase/00-account.apex new file mode 100644 index 0000000..1d29447 --- /dev/null +++ b/dev/job-timeline-demo/scripts/showcase/00-account.apex @@ -0,0 +1,10 @@ +List existing = [SELECT Id FROM Account WHERE Name = 'Acme Order #00124' LIMIT 1]; +if (existing.isEmpty()) insert new Account(Name = 'Acme Order #00124'); +List lines = [SELECT Id FROM Account WHERE Name LIKE 'Line %']; +if (lines.size() < 24) { + List missing = new List(); + for (Integer i = lines.size() + 1; i <= 24; i++) { + missing.add(new Account(Name = 'Line ' + i)); + } + insert missing; +} diff --git a/dev/job-timeline-demo/scripts/showcase/01-stock.apex b/dev/job-timeline-demo/scripts/showcase/01-stock.apex new file mode 100644 index 0000000..53134d3 --- /dev/null +++ b/dev/job-timeline-demo/scripts/showcase/01-stock.apex @@ -0,0 +1,10 @@ +Account order = [SELECT Id FROM Account WHERE Name = 'Acme Order #00124' LIMIT 1]; +OrderStepJob first = new OrderStepJob(); +first.spinMs = 200; +QueueableBuilder chain = Async.queueable(first).name('Load warehouse 1').chainName('Nightly stock sync').linkRecord(order); +for (Integer i = 2; i <= 24; i++) { + OrderStepJob next = new OrderStepJob(); + next.spinMs = 200; + chain = chain.chain(next).name('Load warehouse ' + i).linkRecord(order); +} +chain.enqueue(); diff --git a/dev/job-timeline-demo/scripts/showcase/02-export.apex b/dev/job-timeline-demo/scripts/showcase/02-export.apex new file mode 100644 index 0000000..bef0557 --- /dev/null +++ b/dev/job-timeline-demo/scripts/showcase/02-export.apex @@ -0,0 +1,5 @@ +Account order = [SELECT Id FROM Account WHERE Name = 'Acme Order #00124' LIMIT 1]; +Async.queueable(new OrderStepJob()).name('Collect invoices').chainName('Invoice export').linkRecord(order) + .chain(new OrderStepJob()).name('Write export file').linkRecord(order) + .chain(new OrderStepJob()).name('Send to accounting').linkRecord(order) + .enqueue(); diff --git a/dev/job-timeline-demo/scripts/showcase/03-close.apex b/dev/job-timeline-demo/scripts/showcase/03-close.apex new file mode 100644 index 0000000..6ced8f0 --- /dev/null +++ b/dev/job-timeline-demo/scripts/showcase/03-close.apex @@ -0,0 +1,6 @@ +Account order = [SELECT Id FROM Account WHERE Name = 'Acme Order #00124' LIMIT 1]; +Async.queueable(new OrderStepJob()).name('Check open items').chainName('Month-end close').linkRecord(order) + .chain(new StopOnClosedPeriodJob()).name('Verify period is open').linkRecord(order) + .chain(new OrderStepJob()).name('Post journals').linkRecord(order) + .chain(new OrderStepJob()).name('Lock books').linkRecord(order) + .enqueue(); diff --git a/dev/job-timeline-demo/scripts/showcase/04-reprice.apex b/dev/job-timeline-demo/scripts/showcase/04-reprice.apex new file mode 100644 index 0000000..c4a537d --- /dev/null +++ b/dev/job-timeline-demo/scripts/showcase/04-reprice.apex @@ -0,0 +1,4 @@ +Account order = [SELECT Id FROM Account WHERE Name = 'Acme Order #00124' LIMIT 1]; +List lines = [SELECT Id FROM Account WHERE Name LIKE 'Line %' ORDER BY Name LIMIT 24]; +Async.chunk(new RepriceLinesJob(), ChunkSource.of(lines)).chunkSize(3) + .name('Reprice 24 lines').chainName('Reprice order lines').linkRecord(order).enqueue(); diff --git a/dev/job-timeline-demo/scripts/showcase/05-sync.apex b/dev/job-timeline-demo/scripts/showcase/05-sync.apex new file mode 100644 index 0000000..5b3ffd7 --- /dev/null +++ b/dev/job-timeline-demo/scripts/showcase/05-sync.apex @@ -0,0 +1,8 @@ +Account order = [SELECT Id FROM Account WHERE Name = 'Acme Order #00124' LIMIT 1]; +List lines = [SELECT Id FROM Account WHERE Name LIKE 'Line %' ORDER BY Name LIMIT 12]; +Async.queueable(new OrderStepJob()).name('Prepare order').chainName('Order sync').linkRecord(order) + .chunk(new OrderPagesJob(), ChunkSource.of(lines)).chunkSize(2) + .name('Tax 12 lines').linkRecord(order) + .chain(new AccountRollupJob()).name('Reconcile totals').linkRecord(order) + .chain(new SyncToErpJob()).name('Publish to ERP').linkRecord(order) + .enqueue(); diff --git a/dev/job-timeline-demo/scripts/showcase/06-fulfilment.apex b/dev/job-timeline-demo/scripts/showcase/06-fulfilment.apex new file mode 100644 index 0000000..899f23f --- /dev/null +++ b/dev/job-timeline-demo/scripts/showcase/06-fulfilment.apex @@ -0,0 +1,16 @@ +Account order = [SELECT Id FROM Account WHERE Name = 'Acme Order #00124' LIMIT 1]; +Async.Result reserve = Async.queueable(new ReserveStockJob()) + .name('Reserve stock').chainName('Order fulfilment pipeline').linkRecord(order).chain(); +Async.queueable(new CreateInvoiceJob()).name('Create invoice').linkRecord(order).chain(); +Async.Result commission = Async.queueable(new CommissionSplitJob()) + .name('Commission split').linkRecord(order) + .retry(1).backoff(Async.Backoff.fixed(1)).continueOnJobExecuteFail() + .chain(); +Async.Result rollups = Async.queueable(new AccountRollupJob()).name('Recalculate account rollups').linkRecord(order) + .dependsOn(Async.after(commission).succeeded()).chain(); +Async.queueable(new SyncToErpJob()).name('Sync to ERP').linkRecord(order) + .dependsOn(Async.after(rollups).succeeded()).chain(); +Async.queueable(new OrderStepJob()).name('Open support case').linkRecord(order) + .dependsOn(Async.after(commission).failed()).chain(); +Async.queueable(new OrderStepJob()).name('Notify sales team').linkRecord(order).chain(); +Async.queueable().enqueue(); diff --git a/dev/job-timeline-demo/scripts/showcase/07-live.apex b/dev/job-timeline-demo/scripts/showcase/07-live.apex new file mode 100644 index 0000000..dc38857 --- /dev/null +++ b/dev/job-timeline-demo/scripts/showcase/07-live.apex @@ -0,0 +1,22 @@ +Account order = [SELECT Id FROM Account WHERE Name = 'Acme Order #00124' LIMIT 1]; +OrderStepJob a = new OrderStepJob(); +a.spinMs = 4000; +OrderStepJob b = new OrderStepJob(); +b.spinMs = 6000; +ReserveStockJob c = new ReserveStockJob(); +c.spinMs = 8000; +CreateInvoiceJob d = new CreateInvoiceJob(); +d.spinMs = 8000; +List lines = [SELECT Id FROM Account WHERE Name LIKE 'Line %' ORDER BY Name LIMIT 16]; +AccountRollupJob e = new AccountRollupJob(); +e.spinMs = 8000; +SyncToErpJob f = new SyncToErpJob(); +f.spinMs = 8000; +Async.queueable(a).name('Validate order').chainName('Order approval pipeline').linkRecord(order) + .chain(b).name('Credit check').linkRecord(order) + .chain(c).name('Reserve stock').linkRecord(order) + .chain(d).name('Create invoice').linkRecord(order) + .chunk(new OrderPagesJob(), ChunkSource.of(lines)).chunkSize(2).name('Tax 16 lines').linkRecord(order) + .chain(e).name('Recalculate account rollups').linkRecord(order) + .chain(f).name('Sync to ERP').linkRecord(order) + .enqueue(); diff --git a/dev/job-timeline-demo/sfdx-project.json b/dev/job-timeline-demo/sfdx-project.json new file mode 100644 index 0000000..c683e6f --- /dev/null +++ b/dev/job-timeline-demo/sfdx-project.json @@ -0,0 +1,4 @@ +{ + "packageDirectories": [{ "path": "force-app", "default": true }], + "sourceApiVersion": "66.0" +} diff --git a/package-tests/README.md b/dev/package-tests/README.md similarity index 98% rename from package-tests/README.md rename to dev/package-tests/README.md index a9b3246..f2a9b07 100644 --- a/package-tests/README.md +++ b/dev/package-tests/README.md @@ -44,7 +44,7 @@ Against a namespaced scratch with source deployed (fast, no package build): ```bash sf config set target-org async-lib-btcdev-scratch -./package-tests/run.sh +./dev/package-tests/run.sh ``` Against a real installed package, which is the only check that covers the diff --git a/package-tests/anonymous/ns-test-batch-schedule.apex b/dev/package-tests/anonymous/ns-test-batch-schedule.apex similarity index 100% rename from package-tests/anonymous/ns-test-batch-schedule.apex rename to dev/package-tests/anonymous/ns-test-batch-schedule.apex diff --git a/package-tests/anonymous/ns-test-batch.apex b/dev/package-tests/anonymous/ns-test-batch.apex similarity index 100% rename from package-tests/anonymous/ns-test-batch.apex rename to dev/package-tests/anonymous/ns-test-batch.apex diff --git a/package-tests/anonymous/ns-test-chain-state.apex b/dev/package-tests/anonymous/ns-test-chain-state.apex similarity index 100% rename from package-tests/anonymous/ns-test-chain-state.apex rename to dev/package-tests/anonymous/ns-test-chain-state.apex diff --git a/package-tests/anonymous/ns-test-chunk.apex b/dev/package-tests/anonymous/ns-test-chunk.apex similarity index 97% rename from package-tests/anonymous/ns-test-chunk.apex rename to dev/package-tests/anonymous/ns-test-chunk.apex index a435c22..e3e0d72 100644 --- a/package-tests/anonymous/ns-test-chunk.apex +++ b/dev/package-tests/anonymous/ns-test-chunk.apex @@ -1,4 +1,4 @@ -// NsTestChunkJob is a deployed helper (package-tests/consumer-app) so its pages can be +// NsTestChunkJob is a deployed helper (dev/package-tests/consumer-app) so its pages can be // deserialized across async transactions - proves cloning of a real consumer ChunkJob. public class ConsumerIdChunkJob extends btcdev.ChunkJob implements btcdev.Async.ChunkResettable { public void resetBeforeNextChunk(Integer pageNumber) { diff --git a/dev/package-tests/anonymous/ns-test-cleanup.apex b/dev/package-tests/anonymous/ns-test-cleanup.apex new file mode 100644 index 0000000..0b15285 --- /dev/null +++ b/dev/package-tests/anonymous/ns-test-cleanup.apex @@ -0,0 +1,31 @@ +for (CronTrigger ct : [SELECT Id FROM CronTrigger WHERE CronJobDetail.Name = 'NS_Test_Cleanup']) { + System.abortJob(ct.Id); +} + +List scheduled = btcdev.Async.batchable( + new btcdev.AsyncResultCleanupBatch().failedOlderThanDays(90).othersOlderThanDays(30) + ) + .asSchedulable() + .name('NS_Test_Cleanup') + .cronExpression(new btcdev.CronBuilder().everyDay(3, 0)) + .schedule(); +System.assertEquals(1, scheduled.size(), 'The packaged cleanup batch schedules like the docs say.'); +for (CronTrigger ct : [SELECT Id FROM CronTrigger WHERE CronJobDetail.Name = 'NS_Test_Cleanup']) { + System.abortJob(ct.Id); +} + +btcdev.Async.Result ran = btcdev.Async.batchable( + new btcdev.AsyncResultCleanupBatch().othersOlderThanDays(30) + ) + .scopeSize(2000) + .execute(); +System.assertNotEquals(null, ran.salesforceJobId, 'The ad hoc run enqueues.'); + +try { + new btcdev.AsyncResultCleanupBatch().failedOlderThanDays(0); + System.assert(false, 'zero days must throw'); +} catch (Exception expected) { + System.assert(expected.getMessage().contains('greater than zero'), expected.getMessage()); +} + +System.debug('PASS: ns-test-cleanup'); diff --git a/package-tests/anonymous/ns-test-combined-flow.apex b/dev/package-tests/anonymous/ns-test-combined-flow.apex similarity index 100% rename from package-tests/anonymous/ns-test-combined-flow.apex rename to dev/package-tests/anonymous/ns-test-combined-flow.apex diff --git a/package-tests/anonymous/ns-test-context.apex b/dev/package-tests/anonymous/ns-test-context.apex similarity index 100% rename from package-tests/anonymous/ns-test-context.apex rename to dev/package-tests/anonymous/ns-test-context.apex diff --git a/package-tests/anonymous/ns-test-cron-builder.apex b/dev/package-tests/anonymous/ns-test-cron-builder.apex similarity index 100% rename from package-tests/anonymous/ns-test-cron-builder.apex rename to dev/package-tests/anonymous/ns-test-cron-builder.apex diff --git a/package-tests/anonymous/ns-test-error-handling.apex b/dev/package-tests/anonymous/ns-test-error-handling.apex similarity index 100% rename from package-tests/anonymous/ns-test-error-handling.apex rename to dev/package-tests/anonymous/ns-test-error-handling.apex diff --git a/package-tests/anonymous/ns-test-final-failure-hook.apex b/dev/package-tests/anonymous/ns-test-final-failure-hook.apex similarity index 100% rename from package-tests/anonymous/ns-test-final-failure-hook.apex rename to dev/package-tests/anonymous/ns-test-final-failure-hook.apex diff --git a/package-tests/anonymous/ns-test-mass-enqueue.apex b/dev/package-tests/anonymous/ns-test-mass-enqueue.apex similarity index 100% rename from package-tests/anonymous/ns-test-mass-enqueue.apex rename to dev/package-tests/anonymous/ns-test-mass-enqueue.apex diff --git a/package-tests/anonymous/ns-test-native-schedulable.apex b/dev/package-tests/anonymous/ns-test-native-schedulable.apex similarity index 100% rename from package-tests/anonymous/ns-test-native-schedulable.apex rename to dev/package-tests/anonymous/ns-test-native-schedulable.apex diff --git a/package-tests/anonymous/ns-test-queueable-callouts.apex b/dev/package-tests/anonymous/ns-test-queueable-callouts.apex similarity index 100% rename from package-tests/anonymous/ns-test-queueable-callouts.apex rename to dev/package-tests/anonymous/ns-test-queueable-callouts.apex diff --git a/package-tests/anonymous/ns-test-queueable-chain.apex b/dev/package-tests/anonymous/ns-test-queueable-chain.apex similarity index 100% rename from package-tests/anonymous/ns-test-queueable-chain.apex rename to dev/package-tests/anonymous/ns-test-queueable-chain.apex diff --git a/package-tests/anonymous/ns-test-queueable-enqueue.apex b/dev/package-tests/anonymous/ns-test-queueable-enqueue.apex similarity index 100% rename from package-tests/anonymous/ns-test-queueable-enqueue.apex rename to dev/package-tests/anonymous/ns-test-queueable-enqueue.apex diff --git a/package-tests/anonymous/ns-test-queueable-finalizer.apex b/dev/package-tests/anonymous/ns-test-queueable-finalizer.apex similarity index 100% rename from package-tests/anonymous/ns-test-queueable-finalizer.apex rename to dev/package-tests/anonymous/ns-test-queueable-finalizer.apex diff --git a/package-tests/anonymous/ns-test-queueable-options.apex b/dev/package-tests/anonymous/ns-test-queueable-options.apex similarity index 100% rename from package-tests/anonymous/ns-test-queueable-options.apex rename to dev/package-tests/anonymous/ns-test-queueable-options.apex diff --git a/dev/package-tests/anonymous/ns-test-record-link.apex b/dev/package-tests/anonymous/ns-test-record-link.apex new file mode 100644 index 0000000..42554c9 --- /dev/null +++ b/dev/package-tests/anonymous/ns-test-record-link.apex @@ -0,0 +1,44 @@ +public class RecordLinkJob extends btcdev.QueueableJob { + public override void work() { + btcdev.Async.linkRecords(new List()); + btcdev.Async.linkExternalRecords(new List()); + } +} + +Account linked = new Account(Name = 'NS-LINK-ANON'); +insert linked; + +btcdev.Async.Result chained = btcdev.Async.queueable(new RecordLinkJob()) + .name('Anon link') + .chainName('Anon chain') + .linkRecord(linked) + .linkRecord(linked.Id) + .linkRecords(new List{ linked }) + .linkRecords(new List{ linked.Id }) + .linkRecords(new Set{ linked.Id }) + .linkExternalRecord('NetSuite', 'Customer', '4711') + .linkExternalRecords(new List{ new btcdev.Async.RecordLink('NetSuite', 'Customer', '4711') }) + .chain(); +System.assertEquals('Anon link', chained.job.jobName); +System.assertEquals('Anon chain', chained.job.chainName); +System.assertEquals(2, chained.job.links.size(), 'Seven calls, one record and one external key, two links.'); +System.assertEquals('Salesforce', chained.job.links[0].source); +System.assertEquals('Account', chained.job.links[0].objectName); +System.assertEquals('NetSuite', chained.job.links[1].source); +System.assertEquals('4711', chained.job.links[1].key); + +btcdev.Async.queueable(new RecordLinkJob()).priority(1).chain(); +try { + btcdev.Async.queueable(new RecordLinkJob()).priority(1).nextInChain(); + System.assert(false, 'priority and nextInChain cannot combine'); +} catch (Exception expected) { + System.assert(expected.getMessage().contains('nextInChain'), expected.getMessage()); +} + +btcdev.Async.Result started = btcdev.Async.queueable().enqueue(); +System.assertNotEquals(null, started.salesforceJobId); + +btcdev.Async.RequeueSummary summary = new btcdev.Async.RequeueSummary(); +System.assertNotEquals(null, summary.droppedDependencyByResultId); + +System.debug('PASS: ns-test-record-link'); diff --git a/package-tests/anonymous/ns-test-result-tracking.apex b/dev/package-tests/anonymous/ns-test-result-tracking.apex similarity index 100% rename from package-tests/anonymous/ns-test-result-tracking.apex rename to dev/package-tests/anonymous/ns-test-result-tracking.apex diff --git a/package-tests/anonymous/ns-test-schedulable.apex b/dev/package-tests/anonymous/ns-test-schedulable.apex similarity index 100% rename from package-tests/anonymous/ns-test-schedulable.apex rename to dev/package-tests/anonymous/ns-test-schedulable.apex diff --git a/package-tests/anonymous/ns-test-skip-scheduling.apex b/dev/package-tests/anonymous/ns-test-skip-scheduling.apex similarity index 100% rename from package-tests/anonymous/ns-test-skip-scheduling.apex rename to dev/package-tests/anonymous/ns-test-skip-scheduling.apex diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsAsyncLogger.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsAsyncLogger.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsAsyncLogger.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsAsyncLogger.cls diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsAsyncLogger.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsAsyncLogger.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsAsyncLogger.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsBaseChunkJob.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsBaseChunkJob.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsBaseChunkJob.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsBaseChunkJob.cls diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsBaseChunkJob.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsBaseChunkJob.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsBaseChunkJob.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsBaseQueueableJob.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsBaseQueueableJob.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsBaseQueueableJob.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsBaseQueueableJob.cls diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsBaseQueueableJob.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsBaseQueueableJob.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsBaseQueueableJob.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsBatchFailureLogger.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsBatchFailureLogger.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsBatchFailureLogger.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsBatchFailureLogger.cls diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsBatchFailureLogger.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsBatchFailureLogger.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsBatchFailureLogger.cls-meta.xml rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsBatchFailureLogger.cls-meta.xml diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsFailingBatch.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsFailingBatch.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsFailingBatch.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsFailingBatch.cls diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsFailingBatch.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsFailingBatch.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsFailingBatch.cls-meta.xml rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsFailingBatch.cls-meta.xml diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsJobSerializer.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsJobSerializer.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsJobSerializer.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsJobSerializer.cls diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsJobSerializer.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsJobSerializer.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsJobSerializer.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestAsyncMock.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestAsyncMock.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestAsyncMock.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestAsyncMock.cls diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestAsyncMock.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestAsyncMock.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestAsyncMock.cls-meta.xml rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestAsyncMock.cls-meta.xml diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestBatch.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestBatch.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestBatch.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestBatch.cls diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestBatch.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestBatch.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestBatch.cls-meta.xml rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestBatch.cls-meta.xml diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestBatchFailure.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestBatchFailure.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestBatchFailure.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestBatchFailure.cls diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestBatchFailure.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestBatchFailure.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestBatchFailure.cls-meta.xml rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestBatchFailure.cls-meta.xml diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestChainControl.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChainControl.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestChainControl.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChainControl.cls diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChainControl.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChainControl.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChainControl.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkCallouts.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkCallouts.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestChunkCallouts.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkCallouts.cls diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkCallouts.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkCallouts.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkCallouts.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkJob.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkJob.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestChunkJob.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkJob.cls diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkJob.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkJob.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkJob.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkRun.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkRun.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestChunkRun.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkRun.cls diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkRun.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkRun.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestChunkRun.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestDeepCloneJob.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestDeepCloneJob.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestDeepCloneJob.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestDeepCloneJob.cls diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestDeepCloneJob.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestDeepCloneJob.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestDeepCloneJob.cls-meta.xml rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestDeepCloneJob.cls-meta.xml diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestFailureHook.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestFailureHook.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestFailureHook.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestFailureHook.cls diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestFailureHook.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestFailureHook.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestFailureHook.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerHandler.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerHandler.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerHandler.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerHandler.cls diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerHandler.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerHandler.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerHandler.cls-meta.xml rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerHandler.cls-meta.xml diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerMain.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerMain.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerMain.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerMain.cls diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerMain.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerMain.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerMain.cls-meta.xml rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestFinalizerMain.cls-meta.xml diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestJobTimeline.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestJobTimeline.cls new file mode 100644 index 0000000..334f53d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestJobTimeline.cls @@ -0,0 +1,189 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +@IsTest +public class NsTestJobTimeline { + public static List runOrder = new List(); + + public class NsTimelineException extends Exception { + } + + public class TaggedJob extends btcdev.QueueableJob { + public String tag; + public List insertNext = new List(); + + public TaggedJob() { + } + + public TaggedJob(String tag) { + this.tag = tag; + } + + public override void work() { + NsTestJobTimeline.runOrder.add(tag); + insert new Account(Name = 'NS-' + tag); + for (String next : insertNext) { + btcdev.Async.queueable(new TaggedJob(next)).name(next).nextInChain().enqueue(); + } + } + } + + public class LinkingJob extends btcdev.QueueableJob { + public Id toLink; + + public override void work() { + btcdev.Async.linkRecord(toLink); + } + } + + public class FailingJob extends btcdev.QueueableJob { + public override void work() { + throw new NsTimelineException('ns timeline failure'); + } + } + + @IsTest + static void shouldLinkRecordsAndNamesThroughTheNamespace() { + Account linked = new Account(Name = 'NS-LINKED'); + insert linked; + btcdev.AsyncMock.jobSettings( + new List{ + new btcdev__QueueableJobSetting__mdt( + btcdev__QueueableJobName__c = 'All', + btcdev__TrackJobProgress__c = 'Yes' + ) + } + ); + + Test.startTest(); + btcdev.Async.queueable(new TaggedJob('A')) + .name('Reserve stock') + .chainName('Order pipeline') + .linkRecord(linked) + .linkRecords(new Set{ linked.Id }) + .linkExternalRecord('NetSuite', 'Customer', '4711') + .enqueue(); + Integer enqueuedRows = [ + SELECT COUNT() + FROM btcdev__AsyncResult__c + WHERE btcdev__Status__c = 'ENQUEUED' + ]; + Test.stopTest(); + + System.assertEquals(1, enqueuedRows, 'Tracking writes the row at enqueue.'); + btcdev__AsyncResult__c result = [ + SELECT btcdev__JobName__c, btcdev__ChainName__c, btcdev__Status__c, btcdev__StartedAt__c + FROM btcdev__AsyncResult__c + ]; + System.assertEquals('Reserve stock', result.btcdev__JobName__c); + System.assertEquals('Order pipeline', result.btcdev__ChainName__c); + System.assertEquals('COMPLETED', result.btcdev__Status__c); + System.assertNotEquals(null, result.btcdev__StartedAt__c); + System.assertEquals( + 1, + [ + SELECT COUNT() + FROM btcdev__AsyncResultLink__c + WHERE btcdev__Key__c = :linked.Id + ], + 'One link per record, even when given twice.' + ); + btcdev__AsyncResultLink__c external = [ + SELECT btcdev__Source__c, btcdev__ObjectName__c + FROM btcdev__AsyncResultLink__c + WHERE btcdev__Key__c = '4711' + ]; + System.assertEquals('NetSuite', external.btcdev__Source__c); + System.assertEquals('Customer', external.btcdev__ObjectName__c); + } + + @IsTest + static void shouldLinkRecordsFromInsideWork() { + Account linked = new Account(Name = 'NS-RUNTIME'); + insert linked; + LinkingJob job = new LinkingJob(); + job.toLink = linked.Id; + + Test.startTest(); + btcdev.Async.queueable(job).enqueue(); + Test.stopTest(); + + System.assertEquals( + 1, + [ + SELECT COUNT() + FROM btcdev__AsyncResultLink__c + WHERE btcdev__Key__c = :linked.Id + ] + ); + } + + @IsTest + static void shouldRunNextInChainJobBeforeTheRestOfTheChain() { + TaggedJob first = new TaggedJob('A'); + first.insertNext.add('X'); + + Test.startTest(); + btcdev.Async.queueable(first).chain(new TaggedJob('B')).enqueue(); + Test.stopTest(); + + System.assertEquals(new List{ 'A', 'X', 'B' }, runOrder); + } + + @IsTest + static void shouldRequeueFailedAndSkippedTogetherWithTheDependencyRebuilt() { + btcdev.AsyncMock.jobSettings( + new List{ + new btcdev__QueueableJobSetting__mdt( + btcdev__QueueableJobName__c = 'All', + btcdev__CreateResult__c = true, + btcdev__StoreJobPayload__c = 'Yes', + btcdev__JobSerializerClass__c = 'NsJobSerializer' + ) + } + ); + Test.startTest(); + btcdev.Async.queueable(new FailingJob()) + .name('Blocker') + .continueOnJobExecuteFail() + .chain(new TaggedJob('B')) + .name('Dependent') + .dependsOn(btcdev.Async.afterPrevious().succeeded()) + .enqueue(); + Test.stopTest(); + + Map stored = new Map( + [ + SELECT Id, btcdev__Status__c, btcdev__ChainSequence__c, btcdev__CustomJobId__c + FROM btcdev__AsyncResult__c + WHERE btcdev__RequeueStatus__c = 'Stored' + ] + ); + System.assertEquals(2, stored.size(), 'Failed and skipped rows both carry a payload.'); + Set originalCustomJobIds = new Set(); + for (btcdev__AsyncResult__c row : stored.values()) { + originalCustomJobIds.add(row.btcdev__CustomJobId__c); + } + + btcdev.Async.RequeueSummary summary = btcdev.Async.requeue(stored.keySet()); + + System.assertEquals(2, summary.requeued.size()); + System.assert(summary.droppedDependencyByResultId.isEmpty()); + List replay = new List(); + for (btcdev.QueueableJob job : summary.enqueueResult.queueableChainState.jobs) { + if (!originalCustomJobIds.contains(job.customJobId)) { + replay.add(job); + } + } + System.assertEquals(2, replay.size(), 'Two replays wait in the chain.'); + System.assertEquals('Blocker', replay[0].jobName, 'Chain order is kept on replay.'); + System.assertEquals( + replay[0].customJobId, + replay[1].dependencies[0].resolvedTargetCustomJobId, + 'The dependency points at the replayed blocker.' + ); + } +} diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestJobTimeline.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestJobTimeline.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestJobTimeline.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestLogging.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestLogging.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestLogging.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestLogging.cls diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestLogging.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestLogging.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestLogging.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestOptionsJob.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestOptionsJob.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestOptionsJob.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestOptionsJob.cls diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestOptionsJob.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestOptionsJob.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestOptionsJob.cls-meta.xml rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestOptionsJob.cls-meta.xml diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestRequeue.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestRequeue.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestRequeue.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestRequeue.cls diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestRequeue.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestRequeue.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestRequeue.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestRetryBehavior.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestRetryBehavior.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestRetryBehavior.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestRetryBehavior.cls diff --git a/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestRetryBehavior.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestRetryBehavior.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestRetryBehavior.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestScheduleVerifyJob.cls b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestScheduleVerifyJob.cls similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestScheduleVerifyJob.cls rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestScheduleVerifyJob.cls diff --git a/package-tests/consumer-app/force-app/main/default/classes/NsTestScheduleVerifyJob.cls-meta.xml b/dev/package-tests/consumer-app/force-app/main/default/classes/NsTestScheduleVerifyJob.cls-meta.xml similarity index 100% rename from package-tests/consumer-app/force-app/main/default/classes/NsTestScheduleVerifyJob.cls-meta.xml rename to dev/package-tests/consumer-app/force-app/main/default/classes/NsTestScheduleVerifyJob.cls-meta.xml diff --git a/package-tests/consumer-app/sfdx-project.json b/dev/package-tests/consumer-app/sfdx-project.json similarity index 100% rename from package-tests/consumer-app/sfdx-project.json rename to dev/package-tests/consumer-app/sfdx-project.json diff --git a/package-tests/run.sh b/dev/package-tests/run.sh similarity index 97% rename from package-tests/run.sh rename to dev/package-tests/run.sh index 96dc783..ad45a3b 100755 --- a/package-tests/run.sh +++ b/dev/package-tests/run.sh @@ -7,11 +7,11 @@ export SF_SKIP_NEW_VERSION_CHECK=true # SF_TARGET_ORG in the environment outranks `sf config set target-org` and # silently redirects every org-less sf call. Drop it so the configured -# default org is authoritative (same guard as scripts/agent/verify.sh). +# default org is authoritative (same guard as dev/verify/verify.sh). unset SF_TARGET_ORG SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" GREEN='\033[0;32m' RED='\033[0;31m' @@ -237,7 +237,7 @@ case $MODE in ;; all) deploy_helpers || { - echo -e "${YELLOW}Helper deploy failed — skipping namespace tests${NC}" + echo -e "${YELLOW}Helper deploy failed - skipping namespace tests${NC}" exit 1 } echo "" diff --git a/pmd/ruleset.xml b/dev/pmd/ruleset.xml similarity index 92% rename from pmd/ruleset.xml rename to dev/pmd/ruleset.xml index a1ea9d8..1d62203 100644 --- a/pmd/ruleset.xml +++ b/dev/pmd/ruleset.xml @@ -8,7 +8,7 @@ @@ -279,4 +279,24 @@ + + + 1 + + + + + + + + diff --git a/dev/verify/discover-tests.sh b/dev/verify/discover-tests.sh new file mode 100755 index 0000000..062f07a --- /dev/null +++ b/dev/verify/discover-tests.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Lists every @IsTest method in AsyncTest.cls. Optional first arg filters by +# substring (case-insensitive grep on method name). +# +# discover-tests.sh # all methods, one per line +# discover-tests.sh chain # only methods whose name contains "chain" +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +test_file="force-app/main/default/classes/AsyncTest.cls" +filter="${1:-}" + +awk ' + /@IsTest/ { in_anno=1; next } + in_anno && /static void/ { + if (match($0, /void *[A-Za-z_][A-Za-z0-9_]*/)) { + s = substr($0, RSTART, RLENGTH); + sub(/void */, "", s); + print s; + } + in_anno=0 + } +' "$test_file" | { + if [ -n "$filter" ]; then + grep -i -- "$filter" + else + cat + fi +} diff --git a/dev/verify/ensure-scratch.sh b/dev/verify/ensure-scratch.sh new file mode 100755 index 0000000..29290f3 --- /dev/null +++ b/dev/verify/ensure-scratch.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Ensure async-lib agent scratches exist. Idempotent. +# +# Usage: +# ensure-scratch.sh # ensures both scratches +# ensure-scratch.sh vanilla # only the non-namespaced one +# ensure-scratch.sh namespaced # only the btcdev-namespaced one +# +# Scratches: +# async-lib-scratch (non-namespaced) - vendor-as-source consumer view +# async-lib-btcdev-scratch (btcdev namespace) - package-install consumer view +# +# Env overrides: +# ASYNC_LIB_DEVHUB (default: the target-dev-hub set in sf config) +# ASYNC_LIB_SCRATCH_DAYS (default: 30) +# ASYNC_LIB_SCRATCH_ALIAS (default: async-lib-scratch - overrides vanilla alias) +# ASYNC_LIB_NS_SCRATCH_ALIAS (default: async-lib-btcdev-scratch - overrides namespaced alias) +set -euo pipefail + +# The CLI's update banner goes to stderr; `2>&1` captures on --json calls feed +# it to jq, which then fails to parse. Suppress it. +export SF_SKIP_NEW_VERSION_CHECK=true + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/../.." && pwd)" +cd "$root" + +devhub="${ASYNC_LIB_DEVHUB:-}" +days="${ASYNC_LIB_SCRATCH_DAYS:-30}" +vanilla_alias="${ASYNC_LIB_SCRATCH_ALIAS:-async-lib-scratch}" +ns_alias="${ASYNC_LIB_NS_SCRATCH_ALIAS:-async-lib-btcdev-scratch}" + +if ! command -v jq >/dev/null 2>&1; then + echo "ERROR: jq is required." >&2 + exit 1 +fi + +# ensure_one type ∈ {vanilla, namespaced} +ensure_one() { + local alias="$1" + local type="$2" + local org_json entry is_expired exp needs_create=0 ns + + org_json="$(sf org list --json 2>/dev/null || true)" + entry="$(echo "$org_json" | jq -c --arg a "$alias" '.result.scratchOrgs[]? | select(.alias == $a)' | head -n1)" + + if [ -z "$entry" ]; then + # A scratch authed via `sf org login web` without local devhub auth is + # listed under .result.other, not .result.scratchOrgs. Accept it when + # reachable instead of recreating; namespace comes from the org itself + # because org display carries no namespace for such auths. + if sf org display --target-org "$alias" --json >/dev/null 2>&1; then + ns="$(sf data query --query "SELECT NamespacePrefix FROM Organization" \ + --target-org "$alias" --json 2>/dev/null \ + | jq -r '.result.records[0].NamespacePrefix // empty')" + if [ "$type" = "vanilla" ] && [ -n "$ns" ]; then + echo "ERROR: [$type] '$alias' is reachable but has namespace '$ns'." >&2 + echo " Cannot recreate without devhub auth. Re-auth the correct org." >&2 + exit 1 + fi + if [ "$type" = "namespaced" ] && [ "$ns" != "btcdev" ]; then + echo "ERROR: [$type] '$alias' is reachable but namespace is '${ns:-none}', expected 'btcdev'." >&2 + echo " Cannot recreate without devhub auth. Re-auth the correct org." >&2 + exit 1 + fi + echo "▸ [$type] '$alias' healthy via direct auth (namespace=${ns:-none}, no devhub link)" + return 0 + fi + echo "▸ [$type] '$alias' not found locally - will create" + needs_create=1 + else + is_expired="$(echo "$entry" | jq -r '.isExpired')" + exp="$(echo "$entry" | jq -r '.expirationDate // "unknown"')" + if [ "$is_expired" = "true" ]; then + echo "▸ [$type] '$alias' expired ($exp) - logging out and recreating" + sf org logout --target-org "$alias" --no-prompt >/dev/null 2>&1 || true + needs_create=1 + else + # Verify namespace matches the type + ns="$(sf org display --target-org "$alias" --json 2>/dev/null | jq -r '.result.namespace // empty')" + if [ "$type" = "vanilla" ] && [ -n "$ns" ]; then + echo "▸ [$type] '$alias' has unexpected namespace '$ns' - recreating as non-namespaced" + sf org delete scratch --target-org "$alias" --no-prompt >/dev/null 2>&1 || true + sf org logout --target-org "$alias" --no-prompt >/dev/null 2>&1 || true + needs_create=1 + elif [ "$type" = "namespaced" ] && [ "$ns" != "btcdev" ]; then + echo "▸ [$type] '$alias' missing btcdev namespace (got '${ns:-none}') - recreating as namespaced" + sf org delete scratch --target-org "$alias" --no-prompt >/dev/null 2>&1 || true + sf org logout --target-org "$alias" --no-prompt >/dev/null 2>&1 || true + needs_create=1 + else + echo "▸ [$type] '$alias' healthy (expires $exp, namespace=${ns:-none})" + fi + fi + fi + + if [ "$needs_create" -eq 1 ]; then + local create_args=( + --definition-file config/project-scratch-def.json + --alias "$alias" + --duration-days "$days" + --no-ancestors + --wait 20 + --json + ) + if [ "$type" = "vanilla" ]; then + create_args+=(--no-namespace) + fi + if [ -n "$devhub" ]; then + create_args+=(--target-dev-hub "$devhub") + fi + + echo "▸ [$type] creating scratch via devhub '${devhub:-sf config target-dev-hub}' (duration: ${days}d)" + local create_json status + create_json="$(sf org create scratch "${create_args[@]}" 2>&1)" + status="$(echo "$create_json" | jq -r '.status // 1')" + if [ "$status" != "0" ]; then + echo "ERROR: scratch create failed for '$alias'" >&2 + echo "$create_json" | jq -r '.message // .' >&2 + exit 1 + fi + + exp="$(sf org list --json 2>/dev/null | jq -r --arg a "$alias" '.result.scratchOrgs[]? | select(.alias == $a) | .expirationDate' | head -n1)" + echo "▸ [$type] '$alias' created (expires ${exp:-unknown})" + fi +} + +mode="${1:-both}" +case "$mode" in + vanilla) + ensure_one "$vanilla_alias" vanilla + ;; + namespaced|ns) + ensure_one "$ns_alias" namespaced + ;; + both|all|"") + ensure_one "$vanilla_alias" vanilla + ensure_one "$ns_alias" namespaced + ;; + *) + echo "Usage: ensure-scratch.sh [vanilla|namespaced|both]" >&2 + exit 2 + ;; +esac diff --git a/dev/verify/verify-mock.sh b/dev/verify/verify-mock.sh new file mode 100755 index 0000000..eef5a6d --- /dev/null +++ b/dev/verify/verify-mock.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Phase 2: mock-based smoke test. Runs only the AsyncTest @IsTest methods +# that exercise AsyncMock - they're the fastest behavioral signal because +# they short-circuit System.enqueueJob with mocked QueueableContexts. +# +# Heuristic: extract @IsTest methods whose body references AsyncMock or +# .mockId( on the builder. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +test_file="force-app/main/default/classes/AsyncTest.cls" +if [ ! -f "$test_file" ]; then + echo "ERROR: $test_file not found." >&2 + exit 2 +fi + +# awk pass: collect @IsTest method bodies, keep names whose body matches the +# mock heuristic. Reasonable enough for a 1700-line file with consistent style. +methods=() +while IFS= read -r _method_name; do + [ -n "$_method_name" ] && methods+=("$_method_name") +done < <(awk ' + /@IsTest/ { in_anno=1; next } + in_anno && /static void/ { + if (match($0, /void *[A-Za-z_][A-Za-z0-9_]*/)) { + s = substr($0, RSTART, RLENGTH); + sub(/void */, "", s); + name = s; + } + in_anno=0; in_method=1 + body = $0 + n_open = gsub(/\{/, "{") + n_close = gsub(/\}/, "}") + depth = n_open - n_close + if (depth <= 0 && body ~ /\{/) { + if (body ~ /AsyncMock\.|\.mockId\(/) print name + in_method=0; body="" + } + next + } + in_method { + body = body "\n" $0 + n_open = gsub(/\{/, "{") + n_close = gsub(/\}/, "}") + depth += n_open - n_close + if (depth <= 0 && body ~ /\{/) { + if (body ~ /AsyncMock\.|\.mockId\(/) print name + in_method=0; body="" + } + } +' "$test_file") + +if [ "${#methods[@]}" -eq 0 ]; then + echo "No mock-using tests detected - skipping mock phase." + exit 0 +fi + +org="$(sf config get target-org --json 2>/dev/null | sed -n 's/.*"value": *"\([^"]*\)".*/\1/p')" +if [ -z "$org" ]; then + echo "ERROR: no target-org set." >&2 + exit 3 +fi + +# Build --tests flags. sf accepts repeated --tests of the form ClassName.methodName. +tests_args=() +for m in "${methods[@]}"; do + tests_args+=(--tests "AsyncTest.$m") +done + +echo "── Mock-using tests detected: ${#methods[@]}" +printf ' • %s\n' "${methods[@]}" + +sf apex run test \ + --target-org "$org" \ + --result-format human \ + --wait 15 \ + --synchronous \ + "${tests_args[@]}" diff --git a/dev/verify/verify-static.sh b/dev/verify/verify-static.sh new file mode 100755 index 0000000..b233216 --- /dev/null +++ b/dev/verify/verify-static.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Phase 1: static checks. No execution, no scratch-org dependency for the JS +# parts - but Apex parser validation needs an authenticated org. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +echo "── prettier:verify" +npm run prettier:verify + +echo "── eslint" +if find force-app -type d \( -name lwc -o -name aura \) -print -quit | grep -q .; then + npm run lint +else + # The lint script targets **/{aura,lwc}/**; with no LWC/Aura in the lib there is + # nothing to lint, and ESLint 9 hard-errors on a missing flat config instead of + # no-op'ing. Skip to keep the gate scoped to lib code that actually exists. + echo " (no LWC/Aura sources - skipped)" +fi + +echo "── headers:verify" +npm run headers:verify + +echo "── pmd:verify" +npm run pmd:verify + +echo "── npm test (LWC Jest)" +npm test --silent -- --passWithNoTests + +org="$(sf config get target-org --json 2>/dev/null | sed -n 's/.*"value": *"\([^"]*\)".*/\1/p')" +if [ -n "$org" ]; then + echo "── sf project deploy --dry-run (Apex parser, no commit)" + # `deploy validate` no longer accepts NoTestRun (it must run tests to enable a + # later quick-deploy). The static phase only needs a compile/deployability + # check; tests run in the `full` phase. `deploy start --dry-run` validates + # without committing and still accepts NoTestRun. + sf project deploy start \ + --source-dir force-app \ + --target-org "$org" \ + --dry-run \ + --test-level NoTestRun \ + --wait 10 +else + echo "── sf project deploy validate: SKIPPED (no target-org set)" + echo " For full static coverage, authenticate a scratch org and re-run." +fi diff --git a/dev/verify/verify-tests-full.sh b/dev/verify/verify-tests-full.sh new file mode 100755 index 0000000..1ecc78a --- /dev/null +++ b/dev/verify/verify-tests-full.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Phase 4: full local Apex test run - same surface as CI's RunLocalTests. +# This is the authoritative gate before flipping ready_for_pr. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +org="$(sf config get target-org --json 2>/dev/null | sed -n 's/.*"value": *"\([^"]*\)".*/\1/p')" +if [ -z "$org" ]; then + echo "ERROR: no target-org set." >&2 + exit 3 +fi + +# Deploy current source to ensure org reflects the working tree. +echo "── sf project deploy start (no test execution)" +sf project deploy start \ + --source-dir force-app \ + --target-org "$org" \ + --test-level NoTestRun \ + --ignore-conflicts \ + --wait 15 + +echo "── sf apex run test --test-level RunLocalTests" +sf apex run test \ + --target-org "$org" \ + --test-level RunLocalTests \ + --code-coverage \ + --result-format human \ + --wait 30 \ + --synchronous diff --git a/dev/verify/verify-tests-targeted.sh b/dev/verify/verify-tests-targeted.sh new file mode 100755 index 0000000..e930fff --- /dev/null +++ b/dev/verify/verify-tests-targeted.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Phase 3: run a specific subset of @IsTest methods. +# Usage: verify-tests-targeted.sh Method1 [Method2 ...] +# +# Names may be bare ("shouldChainXyz") or qualified ("AsyncTest.shouldChainXyz"). +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +if [ "$#" -eq 0 ]; then + echo "Usage: verify-tests-targeted.sh Method1 [Method2 ...]" >&2 + exit 2 +fi + +org="$(sf config get target-org --json 2>/dev/null | sed -n 's/.*"value": *"\([^"]*\)".*/\1/p')" +if [ -z "$org" ]; then + echo "ERROR: no target-org set." >&2 + exit 3 +fi + +tests_args=() +for raw in "$@"; do + case "$raw" in + *.*) qualified="$raw" ;; + *) qualified="AsyncTest.$raw" ;; + esac + tests_args+=(--tests "$qualified") +done + +echo "── Targeted test methods:" +printf ' • %s\n' "${tests_args[@]}" | grep -v '^ • --tests' || true + +sf apex run test \ + --target-org "$org" \ + --result-format human \ + --code-coverage \ + --wait 20 \ + --synchronous \ + "${tests_args[@]}" diff --git a/dev/verify/verify.sh b/dev/verify/verify.sh new file mode 100755 index 0000000..80c9288 --- /dev/null +++ b/dev/verify/verify.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Phase orchestrator for async-lib local verification. +# Usage: verify.sh {bootstrap|static|mock|targeted ...|full|ns|all} +# +# Two scratch orgs, two consumer perspectives: +# async-lib-scratch (non-namespaced) - vendor-as-source consumer +# async-lib-btcdev-scratch (btcdev namespace) - package-install consumer +# +# Phases mock|targeted|full run against vanilla. Phase ns runs against the +# namespaced one. Phase static needs no org. `bootstrap` provisions both. +set -euo pipefail + +# This script switches orgs per phase via `sf config set target-org`. A +# SF_TARGET_ORG in the environment outranks that config, which silently pins +# every phase to one org - the `ns` phase then runs run.sh against the +# non-namespaced org and fails with "Invalid type: btcdev.*". Drop it so the +# script's own per-phase target switching is authoritative. +unset SF_TARGET_ORG + +phase="${1:-all}"; shift || true +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/../.." && pwd)" +cd "$root" + +VANILLA_ALIAS="${ASYNC_LIB_SCRATCH_ALIAS:-async-lib-scratch}" +NS_ALIAS="${ASYNC_LIB_NS_SCRATCH_ALIAS:-async-lib-btcdev-scratch}" + +set_target() { + local alias="$1" + sf config set "target-org=$alias" >/dev/null + echo "▸ target-org: $alias" +} + +use_vanilla() { + "$here/ensure-scratch.sh" vanilla + set_target "$VANILLA_ALIAS" +} + +use_namespaced() { + "$here/ensure-scratch.sh" namespaced + set_target "$NS_ALIAS" +} + +run() { + echo + echo "▶ $*" + "$@" +} + +deploy_framework() { + local alias="$1" + echo + echo "▶ sf project deploy start (framework, no tests) → $alias" + sf project deploy start \ + --source-dir force-app \ + --target-org "$alias" \ + --test-level NoTestRun \ + --ignore-conflicts \ + --wait 15 +} + +case "$phase" in + bootstrap) + run "$here/ensure-scratch.sh" both + ;; + static) + run "$here/verify-static.sh" + ;; + mock) + use_vanilla + run "$here/verify-mock.sh" + ;; + targeted) + if [ "$#" -eq 0 ]; then + echo "ERROR: targeted needs at least one test method name." >&2 + echo " Hint: bash $here/discover-tests.sh " >&2 + exit 2 + fi + use_vanilla + run "$here/verify-tests-targeted.sh" "$@" + ;; + full) + use_vanilla + run "$here/verify-tests-full.sh" + ;; + ns) + use_namespaced + deploy_framework "$NS_ALIAS" + run "$root/dev/package-tests/run.sh" + ;; + all) + run "$here/ensure-scratch.sh" both + run "$here/verify-static.sh" + use_vanilla + run "$here/verify-mock.sh" + if [ -n "${TARGETED_TESTS:-}" ]; then + # shellcheck disable=SC2086 + run "$here/verify-tests-targeted.sh" $TARGETED_TESTS + fi + run "$here/verify-tests-full.sh" + use_namespaced + deploy_framework "$NS_ALIAS" + run "$root/dev/package-tests/run.sh" + ;; + *) + echo "Usage: verify.sh {bootstrap|static|mock|targeted ...|full|ns|all}" >&2 + echo " TARGETED_TESTS env var seeds 'all' phase with specific methods." >&2 + exit 2 + ;; +esac + +echo +echo "✓ verify.sh: phase '$phase' passed" diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..000ada2 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,11 @@ +const lwcConfig = require('@salesforce/eslint-config-lwc'); +const jestPlugin = require('eslint-plugin-jest'); + +module.exports = [ + ...lwcConfig.configs.recommended, + { + files: ['**/__tests__/**/*.js', 'dev/jest-mocks/**/*.js'], + ...jestPlugin.configs['flat/recommended'], + languageOptions: { globals: { ...jestPlugin.environments.globals.globals } } + } +]; diff --git a/extras/README.md b/extras/README.md index 7c4a654..7f7cda9 100644 --- a/extras/README.md +++ b/extras/README.md @@ -97,4 +97,4 @@ 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). +See [Requeue](https://async.beyondthecloud.dev/features/requeue). diff --git a/force-app/main/default/classes/Async.cls b/force-app/main/default/classes/Async.cls index bfbe969..992fa74 100644 --- a/force-app/main/default/classes/Async.cls +++ b/force-app/main/default/classes/Async.cls @@ -6,10 +6,14 @@ * * PMD False Positives: * - CognitiveComplexity: a facade over the whole API, and the most complex member scores 1 + * - ExcessivePublicCount: one static entry point per feature is the whole point of the facade + * - TooManyFields: FailureContext mirrors the failure row field by field * - PropertyNamingConventions: `Async.Backoff` is the Apex Fluently accessor idiom, matching * `SOQL.Filter` and `SOQL.SubQuery`, so it reads as a type at the call site **/ -@SuppressWarnings('PMD.CognitiveComplexity,PMD.PropertyNamingConventions') +@SuppressWarnings( + 'PMD.CognitiveComplexity,PMD.ExcessivePublicCount,PMD.TooManyFields,PMD.PropertyNamingConventions' +) public inherited sharing class Async { public static QueueableBuilder queueable(QueueableJob job) { return new QueueableBuilder(job); @@ -63,6 +67,35 @@ public inherited sharing class Async { QueueableManager.get().skipJob(customJobId); } + public static void linkRecord(SObject record) { + linkRecord(record?.Id); + } + + public static void linkRecord(Id recordId) { + QueueableManager.get().linkRecords(new List{ recordId }); + } + + public static void linkRecords(List records) { + QueueableManager.get().linkRecords(QueueableManager.idsOf(records)); + } + + public static void linkRecords(List recordIds) { + QueueableManager.get().linkRecords(recordIds); + } + + public static void linkRecords(Set recordIds) { + QueueableManager.get().linkRecords(recordIds == null ? null : new List(recordIds)); + } + + public static void linkExternalRecord(String source, String objectName, String key) { + QueueableManager.get() + .linkExternalRecords(new List{ new RecordLink(source, objectName, key) }); + } + + public static void linkExternalRecords(List links) { + QueueableManager.get().linkExternalRecords(links); + } + public static RequeueSummary requeue(Id resultId) { return AsyncRequeue.run(new Set{ resultId }); } @@ -245,6 +278,7 @@ public inherited sharing class Async { public class RequeueSummary { public List requeued = new List(); public Map skipReasonByResultId = new Map(); + public Map droppedDependencyByResultId = new Map(); public Result enqueueResult; } @@ -275,6 +309,8 @@ public inherited sharing class Async { public Integer priority; public Integer retryAttempt; public Map info; + public String jobName; + public String chainName; public JobContext(QueueableJob job) { this.customJobId = job.customJobId; @@ -284,6 +320,8 @@ public inherited sharing class Async { this.priority = job.priority; this.retryAttempt = job.retryAttempt; this.info = job.info ?? new Map(); + this.jobName = job.jobName; + this.chainName = job.chainName; } } @@ -303,6 +341,8 @@ public inherited sharing class Async { public String batchPhase; public String batchScope; public Boolean batchScopeTruncated = false; + public String jobName; + public String chainName; public FailureContext(AsyncResult__c batchFailure) { this.asyncType = Async.AsyncType.BATCHABLE; @@ -332,6 +372,8 @@ public inherited sharing class Async { this.maxRetries = job.maxRetries; this.retryHistory = job.retryHistory; this.info = job.info ?? new Map(); + this.jobName = job.jobName; + this.chainName = job.chainName; } private Async.RetryOutcome retryOutcomeFor(QueueableJob job) { @@ -376,6 +418,19 @@ public inherited sharing class Async { } } + @JsonAccess(serializable='always' deserializable='always') + public class RecordLink { + public String source; + public String objectName; + public String key; + + public RecordLink(String source, String objectName, String key) { + this.source = source; + this.objectName = objectName; + this.key = key; + } + } + public class IllegalArgumentException extends Exception { } } diff --git a/force-app/main/default/classes/AsyncTest.cls b/force-app/main/default/classes/AsyncTest.cls index 1036af3..3b51b47 100644 --- a/force-app/main/default/classes/AsyncTest.cls +++ b/force-app/main/default/classes/AsyncTest.cls @@ -32,6 +32,14 @@ private class AsyncTest implements Database.Batchable { private static final String PRIMITIVE_VALUE_INITIAL = 'INITIAL_VALUE'; private static final String CHUNK_PROCESSED = 'CHUNK_PROCESSED'; private static final String LOGGER_CONSTRUCTOR_FAILURE = 'the logger needs a setting that is not there'; + private static Integer dmlStatementsAfterLink; + private static List runOrder = new List(); + private static List chunkSourceRecords; + private static String sequenceSnapshotTag; + private static Map placeholderSequenceSeen = new Map(); + private static Map> insertsByTag = new Map>(); + private static Map priorityChainByTag = new Map(); + private static Async.JobContext capturedJobContext; @IsTest private static void shouldEnqueue60QueueablesSuccessfully() { @@ -393,7 +401,7 @@ private class AsyncTest implements Database.Batchable { job3.uniqueName = 'job3'; QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = new Map{ + QueueableJobSettings.jobSettingByName = new Map{ QueueableManager.QUEUEABLE_JOB_SETTING_ALL => new QueueableJobSetting__mdt( DeveloperName = QueueableManager.QUEUEABLE_JOB_SETTING_ALL, IsDisabled__c = true @@ -422,7 +430,7 @@ private class AsyncTest implements Database.Batchable { QueueableJobTest8 job8 = new QueueableJobTest8(); QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = new Map{ + QueueableJobSettings.jobSettingByName = new Map{ getClassNameWithNamespaceDotPrefix( 'AsyncTest.QueueableJobTest1' ) => new QueueableJobSetting__mdt( @@ -473,7 +481,7 @@ private class AsyncTest implements Database.Batchable { QueueableJobTest2 job2 = new QueueableJobTest2(); QueueableChain chain1 = new QueueableChain(); - QueueableChain.jobSettingByName = new Map{ + QueueableJobSettings.jobSettingByName = new Map{ getClassNameWithNamespaceDotPrefix( 'AsyncTest.QueueableJobTest1' ) => new QueueableJobSetting__mdt( @@ -482,7 +490,7 @@ private class AsyncTest implements Database.Batchable { ) }; QueueableChain chain2 = new QueueableChain(); - QueueableChain.jobSettingByName = new Map{ + QueueableJobSettings.jobSettingByName = new Map{ getClassNameWithNamespaceDotPrefix( 'AsyncTest.QueueableJobTest1' ) => new QueueableJobSetting__mdt( @@ -2489,7 +2497,7 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldPreferAJobSpecificLoggerOverTheOrgWideDefault() { QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = new Map{ + QueueableJobSettings.jobSettingByName = new Map{ QueueableManager.QUEUEABLE_JOB_SETTING_ALL => new QueueableJobSetting__mdt( QueueableJobName__c = QueueableManager.QUEUEABLE_JOB_SETTING_ALL, LoggerClass__c = loggerName('RecordingLogger') @@ -2736,7 +2744,7 @@ private class AsyncTest implements Database.Batchable { QueueableJobTest1 job = new QueueableJobTest1(); QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = new Map{ + QueueableJobSettings.jobSettingByName = new Map{ QueueableManager.QUEUEABLE_JOB_SETTING_ALL => new QueueableJobSetting__mdt( DeveloperName = QueueableManager.QUEUEABLE_JOB_SETTING_ALL, MaxRetries__c = 3, @@ -2759,7 +2767,7 @@ private class AsyncTest implements Database.Batchable { job.maxRetries = 5; QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = new Map{ + QueueableJobSettings.jobSettingByName = new Map{ QueueableManager.QUEUEABLE_JOB_SETTING_ALL => new QueueableJobSetting__mdt( DeveloperName = QueueableManager.QUEUEABLE_JOB_SETTING_ALL, MaxRetries__c = 3 @@ -3128,7 +3136,7 @@ private class AsyncTest implements Database.Batchable { SuccessfulQueueableTest job = new SuccessfulQueueableTest(); QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = resultsEnabledForAll(); + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); chain.addJob(job); QueueableManager.get().setChain(chain); @@ -3158,7 +3166,7 @@ private class AsyncTest implements Database.Batchable { SuccessfulQueueableTest jobB = new SuccessfulQueueableTest(); QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = resultsEnabledForAll(); + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); chain.addJob(jobA); jobB.dependencies = new List{ dependencyOn(jobA.customJobId, Async.Outcome.SUCCESS) @@ -3217,7 +3225,7 @@ private class AsyncTest implements Database.Batchable { IsDisabled__c = true ) ); - QueueableChain.jobSettingByName = settings; + QueueableJobSettings.jobSettingByName = settings; chain.addJob(job); Test.startTest(); @@ -3249,7 +3257,7 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldRecordChainResultsEndToEnd() { QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = resultsEnabledForAll(); + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); QueueableManager.get().setChain(chain); Test.startTest(); @@ -3326,7 +3334,7 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldRecordRetryHistoryOnExhaustionEndToEnd() { QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = resultsEnabledForAll(); + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); QueueableManager.get().setChain(chain); Test.startTest(); @@ -3873,7 +3881,7 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldKeepEveryAttemptInRetryHistoryAfterDiscardingChainChanges() { QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = resultsEnabledForAll(); + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); QueueableManager.get().setChain(chain); Test.startTest(); @@ -3963,7 +3971,7 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldKeepChainRunningWhenOnFinalFailureThrows() { QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = resultsEnabledForAll(); + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); QueueableManager.get().setChain(chain); Test.startTest(); @@ -4102,7 +4110,7 @@ private class AsyncTest implements Database.Batchable { QueueableJobTest1 job = new QueueableJobTest1(); QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = new Map{ + QueueableJobSettings.jobSettingByName = new Map{ QueueableManager.QUEUEABLE_JOB_SETTING_ALL => new QueueableJobSetting__mdt( DeveloperName = QueueableManager.QUEUEABLE_JOB_SETTING_ALL, MaxRetries__c = 2, @@ -4521,7 +4529,7 @@ private class AsyncTest implements Database.Batchable { private static void shouldCreateResultRowForChunk() { List accounts = createAccounts(3); QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = resultsEnabledForAll(); + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); Test.startTest(); QueueableManager.get().setChain(chain); @@ -4905,7 +4913,7 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldFailAJobFromAQueueableMock() { QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = resultsEnabledForAll(); + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); AsyncMock.whenQueueable('mocked-failure') .thenThrow(new CustomException(CUSTOM_ERROR_MESSAGE)); @@ -5000,7 +5008,7 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldFailAndRetryAPageWhenTheSourceThrows() { QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = resultsEnabledForAll(); + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); Test.startTest(); QueueableManager.get().setChain(chain); @@ -5424,7 +5432,7 @@ private class AsyncTest implements Database.Batchable { private static void shouldRecordSummaryResultWhenRunHaltsOnFailure() { List accounts = accountsWithOneFailingRecord(); QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = resultsEnabledForAll(); + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); Test.startTest(); QueueableManager.get().setChain(chain); @@ -5454,7 +5462,7 @@ private class AsyncTest implements Database.Batchable { private static void shouldRecordSummaryResultWhenChainStopsDuringRun() { List accounts = createAccounts(6); QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = resultsEnabledForAll(); + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); Test.startTest(); QueueableManager.get().setChain(chain); @@ -5508,8 +5516,8 @@ private class AsyncTest implements Database.Batchable { private static void shouldLetAJobRecordOptOutOfOrgWidePayloadStorage() { RequeueableJob job = new RequeueableJob('SENSITIVE'); QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = payloadStorageOn(); - QueueableChain.jobSettingByName.put( + QueueableJobSettings.jobSettingByName = payloadStorageOn(); + QueueableJobSettings.jobSettingByName.put( job.className, new QueueableJobSetting__mdt( QueueableJobName__c = job.className, @@ -5527,7 +5535,7 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldNotStoreAPayloadForChunkJobs() { - QueueableChain.jobSettingByName = payloadStorageOn(); + QueueableJobSettings.jobSettingByName = payloadStorageOn(); Async.Result chunked = Async.chunk( new StateCarryingChunkJob(), @@ -5674,7 +5682,7 @@ private class AsyncTest implements Database.Batchable { private static void shouldRequeueAStoredJobAndLinkItBackToTheOriginal() { RequeueableJob job = new RequeueableJob('REPLAYED'); chainStoringPayloads().addJob(job); - QueueableChain.jobSettingByName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL) + QueueableJobSettings.jobSettingByName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL) .CreateResult__c = true; AsyncResult__c original = storedResultFor(job); @@ -5755,7 +5763,7 @@ private class AsyncTest implements Database.Batchable { private static void shouldNotStoreAPayloadOnTheRowOfAJobThatSucceeded() { RequeueableJob job = new RequeueableJob('SUCCEEDED'); QueueableChain chain = chainStoringPayloads(); - QueueableChain.jobSettingByName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL) + QueueableJobSettings.jobSettingByName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL) .CreateResult__c = true; chain.addJob(job); QueueableManager.get().setChain(chain); @@ -5783,7 +5791,7 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldKeepThePayloadThroughARetryThatRestoresEnqueuedState() { - QueueableChain.jobSettingByName = payloadStorageOn(); + QueueableJobSettings.jobSettingByName = payloadStorageOn(); Test.startTest(); Async.queueable(new FailingRequeueableJob()) @@ -5809,7 +5817,7 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldKeepThePayloadThroughADeepClonedRetry() { - QueueableChain.jobSettingByName = payloadStorageOn(); + QueueableJobSettings.jobSettingByName = payloadStorageOn(); Test.startTest(); Async.queueable(new FailingRequeueableJob()) @@ -5861,7 +5869,7 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldDegradeWhenTheRegisteredSerializerReturnsNull() { QueueableChain chain = chainStoringPayloads(); - QueueableChain.jobSettingByName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL) + QueueableJobSettings.jobSettingByName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL) .JobSerializerClass__c = loggerName('NullReturningJobSerializer'); RequeueableJob job = new RequeueableJob('NULL-SERIALIZER'); @@ -5879,8 +5887,8 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldCaptureTheJobBeforeAnyChainBookkeepingLandsOnIt() { RequeueableJob job = new RequeueableJob('PRISTINE'); - QueueableChain.jobSettingByName = payloadStorageOn(); - QueueableChain.jobSettingByName.put( + QueueableJobSettings.jobSettingByName = payloadStorageOn(); + QueueableJobSettings.jobSettingByName.put( job.className, new QueueableJobSetting__mdt(QueueableJobName__c = job.className, MaxRetries__c = 3) ); @@ -6071,7 +6079,7 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldRouteBothHalvesThroughARegisteredSerializer() { QueueableChain chain = chainStoringPayloads(); - QueueableChain.jobSettingByName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL) + QueueableJobSettings.jobSettingByName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL) .JobSerializerClass__c = loggerName('RecordingJobSerializer'); RequeueableJob job = new RequeueableJob('VIA-SERIALIZER'); chain.addJob(job); @@ -6094,7 +6102,7 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldRejectARegisteredClassThatIsNotASerializer() { QueueableChain chain = chainStoringPayloads(); - QueueableChain.jobSettingByName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL) + QueueableJobSettings.jobSettingByName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL) .JobSerializerClass__c = loggerName('RecordingLogger'); RequeueableJob job = new RequeueableJob('NOT-A-SERIALIZER'); @@ -6740,6 +6748,7 @@ private class AsyncTest implements Database.Batchable { new Map{ 'attributes' => new Map{ 'type' => 'BatchApexErrorEvent' }, 'AsyncApexJobId' => FAILED_BATCH_JOB_ID, + 'ReplayId' => '1', 'Phase' => BatchFailureRecorder.PHASE_EXECUTE, 'ExceptionType' => exceptionType, 'Message' => AsyncTestBatch.FAILURE_MESSAGE, @@ -6754,6 +6763,1628 @@ private class AsyncTest implements Database.Batchable { return getClassNameWithNamespaceDotPrefix('AsyncTestBatch'); } + @IsTest + private static void shouldSortNextInChainJobsAfterFinalizersAndBeforePriority() { + QueueableJob finalizer = new StopChainFinalizer(); + finalizer.parentCustomJobId = 'parent'; + finalizer.chainSequence = 4; + QueueableJob prioritized = new SuccessfulQueueableTest(); + prioritized.priority = 1; + prioritized.chainSequence = 3; + QueueableJob inserted = new SuccessfulQueueableTest(); + inserted.nextInChain = true; + inserted.chainSequence = 9; + QueueableJob plain = new SuccessfulQueueableTest(); + plain.chainSequence = 2; + List jobs = new List{ plain, inserted, prioritized, finalizer }; + + jobs.sort(); + + Assert.areEqual( + new List{ 4, 9, 3, 2 }, + chainSequencesOf(jobs), + 'Order is finalizer, nextInChain, priority, sequence.' + ); + } + + @IsTest + private static void shouldDedupeLinksKeepingOrder() { + Id first = fakeAccountId(1); + Id second = fakeAccountId(2); + Id third = fakeAccountId(3); + QueueableJob job = new SuccessfulQueueableTest(); + + job.linkRecords(new List{ first, second, first, third }); + job.linkRecords(new List{ second }); + job.linkRecords(null); + job.linkExternalRecords( + new List{ + new Async.RecordLink('NetSuite', 'Customer', '123'), + new Async.RecordLink('NetSuite', 'Customer', '123'), + new Async.RecordLink(null, null, '123') + } + ); + + Assert.areEqual( + new List{ first, second, third, '123', '123' }, + keysOf(job.links), + 'Same key under another source is another link.' + ); + Assert.areEqual( + new List{ 'Salesforce', 'Salesforce', 'Salesforce', 'NetSuite', null }, + sourcesOf(job.links) + ); + Assert.areEqual('Account', job.links[0].objectName, 'Set from the Id for Salesforce.'); + Assert.areEqual('Customer', job.links[3].objectName, 'As given for an external key.'); + } + + @IsTest + private static void shouldRejectAnExternalLinkWithoutAKey() { + String message; + try { + Async.queueable(new SuccessfulQueueableTest()) + .linkExternalRecord('NetSuite', 'Customer', ' '); + } catch (IllegalArgumentException e) { + message = e.getMessage(); + } + Assert.areEqual(QueueableManager.ERROR_MESSAGE_LINK_KEY_REQUIRED, message); + } + + private static List keysOf(List links) { + List keys = new List(); + for (Async.RecordLink link : links) { + keys.add(link.key); + } + return keys; + } + + private static List sourcesOf(List links) { + List sources = new List(); + for (Async.RecordLink link : links) { + sources.add(link.source); + } + return sources; + } + + @IsTest + private static void shouldClampMaxRecordLinksAndWarn() { + QueueableJob job = new SuccessfulQueueableTest(); + QueueableJobSettings.jobSettingByName = new Map{ + QueueableManager.QUEUEABLE_JOB_SETTING_ALL => new QueueableJobSetting__mdt( + QueueableJobName__c = QueueableManager.QUEUEABLE_JOB_SETTING_ALL, + MaxRecordLinks__c = 9000 + ) + }; + + Integer limitApplied = QueueableManager.maxRecordLinksFor(job); + + Assert.areEqual(QueueableManager.MAX_RECORD_LINKS_CAP, limitApplied); + Assert.areEqual( + String.format( + QueueableManager.WARNING_MAX_RECORD_LINKS_EXCEEDS_CAP, + new List{ + '9000', + job.className, + String.valueOf(QueueableManager.MAX_RECORD_LINKS_CAP) + } + ), + job.retryHistory + ); + } + + @IsTest + private static void shouldFallBackToDefaultMaxRecordLinks() { + QueueableJob job = new SuccessfulQueueableTest(); + QueueableJobSettings.jobSettingByName = new Map(); + + Assert.areEqual( + QueueableManager.DEFAULT_MAX_RECORD_LINKS, + QueueableManager.maxRecordLinksFor(job) + ); + + QueueableJobSettings.jobSettingByName.put( + job.className, + new QueueableJobSetting__mdt( + QueueableJobName__c = job.className, + MaxRecordLinks__c = 10 + ) + ); + + Assert.areEqual(10, QueueableManager.maxRecordLinksFor(job)); + Assert.isNull(job.retryHistory, 'A value inside the cap records no warning.'); + } + + @IsTest + private static void shouldCarryNamesAndLinksOnTheJob() { + Async.Result result = Async.queueable(new SuccessfulQueueableTest()) + .name('Reserve stock') + .chainName('Order pipeline') + .linkRecord(new Account(Id = fakeAccountId(1))) + .linkRecord(fakeAccountId(2)) + .linkRecords(new List{ new Account(Id = fakeAccountId(3)) }) + .linkRecords(new List{ fakeAccountId(4) }) + .linkRecords(new Set{ fakeAccountId(5) }) + .chain(); + + Assert.areEqual('Reserve stock', result.job.jobName); + Assert.areEqual('Order pipeline', result.job.chainName); + Assert.areEqual( + new List{ + fakeAccountId(1), + fakeAccountId(2), + fakeAccountId(3), + fakeAccountId(4), + fakeAccountId(5) + }, + keysOf(result.job.links) + ); + } + + @IsTest + private static void shouldCarryNamesAndEveryLinkFormOnAChunkJob() { + Async.Result result = Async.chunk( + new OrderMarkerChunkJob(), + ChunkSource.of(createAccounts(1)) + ) + .name('Reprice') + .chainName('Nightly') + .linkRecord(new Account(Id = fakeAccountId(1))) + .linkRecord(fakeAccountId(2)) + .linkRecords(new List{ new Account(Id = fakeAccountId(3)) }) + .linkRecords(new List{ fakeAccountId(4) }) + .linkRecords(new Set{ fakeAccountId(5) }) + .linkExternalRecord('NetSuite', 'Customer', '4711') + .linkExternalRecords( + new List{ new Async.RecordLink('SAP', 'Order', '42') } + ) + .chain(); + + Assert.areEqual('Reprice', result.job.jobName); + Assert.areEqual('Nightly', result.job.chainName); + Assert.areEqual( + new List{ + fakeAccountId(1), + fakeAccountId(2), + fakeAccountId(3), + fakeAccountId(4), + fakeAccountId(5), + '4711', + '42' + }, + keysOf(result.job.links) + ); + } + + @IsTest + private static void shouldRejectPriorityCombinedWithNextInChainOnAChunkJob() { + String priorityFirst; + String nextInChainFirst; + + try { + Async.chunk(new UngatedChunkJob(), ChunkSource.of(createAccounts(1))) + .priority(1) + .nextInChain(); + } catch (Exception ex) { + priorityFirst = ex.getMessage(); + } + try { + Async.chunk(new UngatedChunkJob(), ChunkSource.of(createAccounts(1))) + .nextInChain() + .priority(1); + } catch (Exception ex) { + nextInChainFirst = ex.getMessage(); + } + + Assert.areEqual(QueueableManager.ERROR_MESSAGE_PRIORITY_WITH_NEXT_IN_CHAIN, priorityFirst); + Assert.areEqual( + QueueableManager.ERROR_MESSAGE_PRIORITY_WITH_NEXT_IN_CHAIN, + nextInChainFirst + ); + } + + @IsTest + private static void shouldTakeAListOfExternalLinksOnTheQueueableBuilder() { + Async.Result result = Async.queueable(new SuccessfulQueueableTest()) + .linkExternalRecords( + new List{ new Async.RecordLink('SAP', 'Order', '42') } + ) + .chain(); + + Assert.areEqual(new List{ '42' }, keysOf(result.job.links)); + Assert.areEqual(new List{ 'SAP' }, sourcesOf(result.job.links)); + } + + @IsTest + private static void shouldBufferEveryLinkFormFromInsideARunningJob() { + QueueableChain chain = new QueueableChain(); + chain.addJob(new EveryLinkFormJob()); + QueueableManager.get().setChain(chain); + + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + + Assert.areEqual( + new List{ + fakeAccountId(1), + fakeAccountId(2), + fakeAccountId(3), + fakeAccountId(4), + '4711', + '42' + }, + keysOf(chain.getCurrentJob().links) + ); + } + + @IsTest + private static void shouldRejectAnExternalLinkOutsideAJob() { + String message; + + try { + Async.linkExternalRecord('NetSuite', 'Customer', '4711'); + } catch (Exception ex) { + message = ex.getMessage(); + } + + Assert.areEqual(QueueableManager.ERROR_MESSAGE_LINK_RECORD_OUTSIDE_JOB, message); + } + + @IsTest + private static void shouldHaveNoOutcomeForAStatusThatIsNotFinal() { + Assert.isNull(QueueableManager.outcomeOf(QueueableManager.STATUS_ENQUEUED)); + } + + @IsTest + private static void shouldKeepEachChainTogetherWhenRequeueingSeveral() { + QueueableChain firstCapture = chainStoringPayloads(); + QueueableChain secondCapture = chainStoringPayloads(); + RequeueableJob firstOfA = new RequeueableJob('A1'); + RequeueableJob secondOfA = new RequeueableJob('A2'); + RequeueableJob firstOfB = new RequeueableJob('B1'); + RequeueableJob secondOfB = new RequeueableJob('B2'); + firstCapture.addJob(firstOfA); + firstCapture.addJob(secondOfA); + secondCapture.addJob(firstOfB); + secondCapture.addJob(secondOfB); + Set rowIds = new Set{ + storedRowInChain(secondOfB, 2, null).Id, + storedRowInChain(secondOfA, 2, null).Id, + storedRowInChain(firstOfB, 1, null).Id, + storedRowInChain(firstOfA, 1, null).Id + }; + + Async.RequeueSummary summary = Async.requeue(rowIds); + + Assert.areNotEqual(firstOfA.chainId, firstOfB.chainId); + Boolean chainAFirst = firstOfA.chainId.compareTo(firstOfB.chainId) < 0; + Assert.areEqual( + chainAFirst + ? new List{ 'A1', 'A2', 'B1', 'B2' } + : new List{ 'B1', 'B2', 'A1', 'A2' }, + tagsOf(summary.enqueueResult.queueableChainState.jobs) + ); + } + + @IsTest + private static void shouldRequeueOldestFirstWhenRowsShareASequence() { + QueueableChain capture = chainStoringPayloads(); + RequeueableJob newer = new RequeueableJob('NEWER'); + RequeueableJob older = new RequeueableJob('OLDER'); + capture.addJob(newer); + capture.addJob(older); + AsyncResult__c newerRow = storedRowInChain(newer, 1, null); + AsyncResult__c olderRow = storedRowInChain(older, 1, null); + Test.setCreatedDate(newerRow.Id, System.now().addHours(-1)); + Test.setCreatedDate(olderRow.Id, System.now().addHours(-2)); + + Async.RequeueSummary summary = Async.requeue(new Set{ newerRow.Id, olderRow.Id }); + + Assert.areEqual( + new List{ 'OLDER', 'NEWER' }, + tagsOf(summary.enqueueResult.queueableChainState.jobs) + ); + } + + @IsTest + private static void shouldNotThrowWhenAFrameworkFailureCannotBeRecordedEither() { + new AsyncResultWriter() + .recordFrameworkFailure( + tooLongForChainId(), + null, + new DmlException('Original failure') + ); + + Assert.areEqual(0, [SELECT COUNT() FROM AsyncResult__c]); + } + + @IsTest + private static void shouldNotRecordAJobThatAlreadyHasAResult() { + RequeueableJob job = trackedJob(); + job.resultCreated = true; + + new AsyncResultWriter() + .recordResults(new List{ job }, new Map()); + + Assert.areEqual(0, [SELECT COUNT() FROM AsyncResult__c]); + } + + @IsTest + private static void shouldWarnAndCarryOnWhenTheEnqueuedRowCannotBeInserted() { + RequeueableJob job = trackedJob(); + job.chainId = tooLongForChainId(); + + new AsyncResultWriter().createEnqueuedPlaceholders(new List{ job }, null); + + Assert.isTrue( + hasWarning(job, QueueableManager.WARNING_PROGRESS_ROW_NOT_WRITTEN), + job.retryHistory + ); + Assert.areEqual(0, [SELECT COUNT() FROM AsyncResult__c]); + } + + @IsTest + private static void shouldWarnWhenAResequencedRowCannotBeUpdated() { + RequeueableJob job = trackedJob(); + AsyncResultWriter writer = writerWhoseEnqueuedRowWasDeleted(job); + writer.markResequenced(job.customJobId); + + writer.createEnqueuedPlaceholders(new List{ job }, null); + + Assert.isTrue( + hasWarning(job, QueueableManager.WARNING_PROGRESS_ROW_NOT_WRITTEN), + job.retryHistory + ); + } + + @IsTest + private static void shouldWarnWhenAnEnqueuedRowCannotBeDeleted() { + RequeueableJob job = trackedJob(); + AsyncResultWriter writer = writerWhoseEnqueuedRowWasDeleted(job); + + writer.deletePlaceholdersOfUnprocessedJobs(new List{ job }, job); + + Assert.isTrue( + hasWarning(job, QueueableManager.WARNING_PROGRESS_ROW_NOT_WRITTEN), + job.retryHistory + ); + } + + @IsTest + private static void shouldWarnWhenTheEnqueuedRowCannotBeMovedToTheNextAttempt() { + RequeueableJob job = trackedJob(); + AsyncResultWriter writer = writerWhoseEnqueuedRowWasDeleted(job); + + writer.updatePlaceholderForRetry(job, 1, 0); + + Assert.isTrue( + hasWarning(job, QueueableManager.WARNING_PROGRESS_ROW_NOT_WRITTEN), + job.retryHistory + ); + } + + @IsTest + private static void shouldWarnAndKeepTheRowWhenARecordLinkCannotBeWritten() { + RequeueableJob job = trackedJob(); + job.linkExternalRecords( + new List{ + new Async.RecordLink('NetSuite', 'Customer', 'k'.repeat(256)) + } + ); + + new AsyncResultWriter().createEnqueuedPlaceholders(new List{ job }, null); + + Assert.isTrue( + hasWarning(job, QueueableManager.WARNING_RECORD_LINKS_NOT_WRITTEN), + job.retryHistory + ); + Assert.areEqual(1, [SELECT COUNT() FROM AsyncResult__c]); + Assert.areEqual(0, [SELECT COUNT() FROM AsyncResultLink__c]); + } + + private static RequeueableJob trackedJob() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + RequeueableJob job = new RequeueableJob('TRACKED'); + new QueueableChain().addJob(job); + return job; + } + + private static AsyncResultWriter writerWhoseEnqueuedRowWasDeleted(QueueableJob job) { + AsyncResultWriter writer = new AsyncResultWriter(); + writer.createEnqueuedPlaceholders(new List{ job }, null); + delete [SELECT Id FROM AsyncResult__c]; + return writer; + } + + private static String tooLongForChainId() { + return 'x'.repeat(AsyncResult__c.ChainId__c.getDescribe().getLength() + 1); + } + + private static Boolean hasWarning(QueueableJob job, String warningTemplate) { + String expectedStart = String.format( + warningTemplate.substringBefore('{1}'), + new List{ job.className } + ); + return job.retryHistory?.contains(expectedStart) == true; + } + + @IsTest + private static void shouldRejectPriorityCombinedWithNextInChain() { + String priorityFirst; + String nextInChainFirst; + + try { + Async.queueable(new SuccessfulQueueableTest()).priority(1).nextInChain(); + } catch (Exception ex) { + priorityFirst = ex.getMessage(); + } + try { + Async.queueable(new SuccessfulQueueableTest()).nextInChain().priority(1); + } catch (Exception ex) { + nextInChainFirst = ex.getMessage(); + } + + Assert.areEqual(QueueableManager.ERROR_MESSAGE_PRIORITY_WITH_NEXT_IN_CHAIN, priorityFirst); + Assert.areEqual( + QueueableManager.ERROR_MESSAGE_PRIORITY_WITH_NEXT_IN_CHAIN, + nextInChainFirst + ); + } + + @IsTest + private static void shouldRejectLinkRecordOutsideAJob() { + String message; + + try { + Async.linkRecord(fakeAccountId(1)); + } catch (Exception ex) { + message = ex.getMessage(); + } + + Assert.areEqual(QueueableManager.ERROR_MESSAGE_LINK_RECORD_OUTSIDE_JOB, message); + } + + @IsTest + private static void shouldBufferLinkedRecordsOnTheCurrentJobWithoutDml() { + RecordLinkingJob job = new RecordLinkingJob(); + job.toLink = fakeAccountId(7); + QueueableChain chain = new QueueableChain(); + chain.addJob(job); + QueueableManager.get().setChain(chain); + + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + + Assert.areEqual(0, dmlStatementsAfterLink, 'Linking buffers ids, it never writes.'); + Assert.areEqual(new List{ fakeAccountId(7) }, keysOf(chain.getCurrentJob().links)); + } + + @IsTest + private static void shouldExposeNamesOnLifecycleContexts() { + Async.queueable(new ContextCapturingJob()).name('Named').chainName('Chain').chain(); + + Assert.areEqual('Named', capturedJobContext.jobName); + Assert.areEqual('Chain', capturedJobContext.chainName); + } + + @IsTest + private static void shouldInsertNextInChainJobRightAfterTheCurrentOne() { + insertsByTag = new Map>{ 'A' => new List{ 'X' } }; + + Test.startTest(); + Async.queueable(orderMarker('A')).chain(orderMarker('B')).chain(orderMarker('C')).enqueue(); + Test.stopTest(); + + Assert.areEqual(new List{ 'A', 'X', 'B', 'C' }, runOrder); + } + + @IsTest + private static void shouldRunNestedNextInChainJobsInInsertionOrder() { + insertsByTag = new Map>{ + 'A' => new List{ 'X', 'Y' }, + 'X' => new List{ 'Z' } + }; + + Test.startTest(); + Async.queueable(orderMarker('A')).chain(orderMarker('B')).enqueue(); + Test.stopTest(); + + Assert.areEqual(new List{ 'A', 'X', 'Z', 'Y', 'B' }, runOrder); + } + + @IsTest + private static void shouldRunNextInChainJobAfterTheCurrentChunkPage() { + List accounts = createAccounts(6); + insertsByTag = new Map>{ 'page1' => new List{ 'X' } }; + + Test.startTest(); + Async.chunk(new OrderMarkerChunkJob(), ChunkSource.of(accounts)).chunkSize(2).enqueue(); + Test.stopTest(); + + Assert.areEqual(new List{ 'page1', 'X', 'page2', 'page3' }, runOrder); + } + + @IsTest + private static void shouldRejectNextInChainOutsideAChain() { + String message; + + try { + Async.queueable(orderMarker('X')).nextInChain().enqueue(); + } catch (Exception ex) { + message = ex.getMessage(); + } + + Assert.areEqual(QueueableManager.ERROR_MESSAGE_NEXT_IN_CHAIN_OUTSIDE_CHAIN, message); + } + + @IsTest + private static void shouldNotLetPriorityOutrankNextInChain() { + insertsByTag = new Map>{ 'A' => new List{ 'X' } }; + priorityChainByTag = new Map{ 'A' => 'P' }; + + Test.startTest(); + Async.queueable(orderMarker('A')).chain(orderMarker('B')).enqueue(); + Test.stopTest(); + + Assert.areEqual(new List{ 'A', 'X', 'P', 'B' }, runOrder); + } + + @IsTest + private static void shouldResequencePlaceholdersAfterANextInChainInsert() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + insertsByTag = new Map>{ 'A' => new List{ 'X' } }; + sequenceSnapshotTag = 'X'; + + Test.startTest(); + Async.queueable(orderMarker('A')).chain(orderMarker('B')).chain(orderMarker('C')).enqueue(); + Test.stopTest(); + + Assert.areEqual(new List{ 'A', 'X', 'B', 'C' }, runOrder); + Assert.areEqual(3, placeholderSequenceSeen.get('B'), 'B\'s placeholder moved behind X.'); + Assert.areEqual(4, placeholderSequenceSeen.get('C')); + } + + @IsTest + private static void shouldPlaceANextInChainChunkRunOnceForAllItsPages() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + chunkSourceRecords = createAccounts(6); + + Test.startTest(); + Async.queueable(new ChunkInsertingJob()) + .chain(orderMarker('B')) + .chain(orderMarker('C')) + .enqueue(); + Test.stopTest(); + + Assert.areEqual(new List{ 'A', 'page1', 'page2', 'page3', 'B', 'C' }, runOrder); + AsyncResult__c inserter = [ + SELECT CustomJobId__c + FROM AsyncResult__c + WHERE ClassName__c LIKE '%ChunkInsertingJob' + ]; + Map rowByName = new Map(); + for (AsyncResult__c row : [ + SELECT JobName__c, ChainSequence__c, AddedByCustomJobId__c, ChunkPage__c + FROM AsyncResult__c + WHERE JobName__c != NULL OR ChunkPage__c != NULL + ]) { + rowByName.put(row.JobName__c ?? 'page' + row.ChunkPage__c.intValue(), row); + } + Assert.areEqual( + 3, + rowByName.get('B').ChainSequence__c, + 'Queued jobs shift once, not once per page.' + ); + Assert.areEqual(4, rowByName.get('C').ChainSequence__c); + for (Integer pageNumber = 1; pageNumber <= 3; pageNumber++) { + AsyncResult__c pageRow = rowByName.get('page' + pageNumber); + Assert.areEqual(2, pageRow.ChainSequence__c, 'Every page keeps the run slot.'); + Assert.areEqual(inserter.CustomJobId__c, pageRow.AddedByCustomJobId__c); + } + } + + @IsTest + private static void shouldRunAJobSerializedBeforeTheProgressFieldsExisted() { + Map serialized = (Map) JSON.deserializeUntyped( + JSON.serialize(new SuccessfulQueueableTest()) + ); + serialized.remove('nextInChain'); + serialized.remove('recordLinksWritten'); + SuccessfulQueueableTest legacy = (SuccessfulQueueableTest) JSON.deserialize( + JSON.serialize(serialized), + SuccessfulQueueableTest.class + ); + Assert.isNull(legacy.nextInChain, 'Pre-3.2 chain state has no such field.'); + + Test.startTest(); + Async.queueable(legacy) + .linkRecord(fakeAccountId(1)) + .chain(new SuccessfulQueueableTest()) + .enqueue(); + Test.stopTest(); + + Assert.areEqual(2, [SELECT COUNT() FROM Account]); + Assert.areEqual(1, [SELECT COUNT() FROM AsyncResultLink__c]); + } + + @IsTest + private static void shouldReinsertTheResultWhenThePlaceholderWasDeletedMidFlight() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + + Test.startTest(); + Async.queueable(markerJob('A', false)).chain(markerJob('B', false)).enqueue(); + delete [SELECT Id FROM AsyncResult__c WHERE Status__c = :QueueableManager.STATUS_ENQUEUED]; + Test.stopTest(); + + Assert.areEqual(2, [SELECT COUNT() FROM Account], 'The chain keeps running.'); + List rows = [ + SELECT Status__c, RetryHistory__c + FROM AsyncResult__c + WHERE Status__c = :QueueableManager.STATUS_COMPLETED + ]; + Assert.areEqual(2, rows.size()); + Assert.isTrue( + rows[0].RetryHistory__c.contains('could not be updated'), + 'The replaced row says why: ' + rows[0].RetryHistory__c + ); + } + + @IsTest + private static void shouldNotDuplicateALinkWrittenWithThePlaceholder() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + RecordLinkingJob job = new RecordLinkingJob(); + job.toLink = fakeAccountId(7); + + Test.startTest(); + Async.queueable(job).linkRecord(fakeAccountId(7)).enqueue(); + Test.stopTest(); + + Assert.areEqual(1, [SELECT COUNT() FROM AsyncResultLink__c]); + } + + @IsTest + private static void shouldRecordTheDmlBudgetTruncationOnTheResultRow() { + AsyncResultWriter.dmlRowReserve = Limits.getLimitDmlRows() - 3; + + Test.startTest(); + Async.queueable(markerJob('A', false)) + .linkRecords( + new List{ + fakeAccountId(1), + fakeAccountId(2), + fakeAccountId(3), + fakeAccountId(4) + } + ) + .enqueue(); + Test.stopTest(); + + AsyncResult__c row = [SELECT RetryHistory__c FROM AsyncResult__c]; + Assert.isTrue( + row.RetryHistory__c?.contains('no DML rows left') == true, + 'The row says links were dropped: ' + row.RetryHistory__c + ); + Assert.isTrue([SELECT COUNT() FROM AsyncResultLink__c] < 4); + } + + @IsTest + private static void shouldRemoveThePlaceholderWhenTheEnqueueFails() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + AsyncOptions options = new AsyncOptions(); + options.DuplicateSignature = QueueableDuplicateSignature.Builder() + .addString(TEST_SIGNATURE_NAME) + .build(); + String message; + + Test.startTest(); + Async.queueable(markerJob('A', false)).asyncOptions(options).enqueue(); + Async.queueable(markerJob('B', false)) + .asyncOptions(options) + .continueOnJobEnqueueFail() + .enqueue(); + try { + Async.queueable(markerJob('C', false)).asyncOptions(options).enqueue(); + } catch (Exception ex) { + message = ex.getMessage(); + } + Integer placeholders = [ + SELECT COUNT() + FROM AsyncResult__c + WHERE Status__c = :QueueableManager.STATUS_ENQUEUED + ]; + Test.stopTest(); + + Assert.isNotNull(message, 'The duplicate signature rejects the third enqueue.'); + Assert.areEqual(1, placeholders, 'Only the job that was really enqueued keeps its row.'); + } + + @IsTest + private static void shouldRecordAConfigurationWarningOnce() { + QueueableJob job = new SuccessfulQueueableTest(); + + job.recordConfigurationWarning('clamped'); + job.recordConfigurationWarning('clamped'); + + Assert.areEqual('clamped', job.retryHistory); + } + + @IsTest + private static void shouldWriteEnqueuedPlaceholdersAtEnqueueWhenTrackingProgress() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + + Test.startTest(); + Async.queueable(markerJob('A', false)) + .name('First') + .chainName('Chain') + .chain(markerJob('B', false)) + .chain(markerJob('C', false)) + .enqueue(); + List placeholders = [ + SELECT Status__c, SalesforceJobId__c, ChainSequence__c, JobName__c, ChainName__c + FROM AsyncResult__c + ORDER BY ChainSequence__c + ]; + Test.stopTest(); + + Assert.areEqual(3, placeholders.size(), 'Every job of the chain gets a row at enqueue.'); + for (AsyncResult__c placeholder : placeholders) { + Assert.areEqual(QueueableManager.STATUS_ENQUEUED, placeholder.Status__c); + Assert.areEqual('Chain', placeholder.ChainName__c); + } + Assert.isNotNull(placeholders[0].SalesforceJobId__c, 'The job handed to the platform.'); + Assert.isNull(placeholders[1].SalesforceJobId__c, 'Not handed over yet.'); + Assert.areEqual('First', placeholders[0].JobName__c); + Assert.areEqual( + new List{ 1, 2, 3 }, + new List{ + placeholders[0].ChainSequence__c, + placeholders[1].ChainSequence__c, + placeholders[2].ChainSequence__c + } + ); + } + + @IsTest + private static void shouldUpdateThePlaceholderInsteadOfInsertingASecondRow() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + + Test.startTest(); + Async.queueable(markerJob('A', false)).chain(markerJob('B', false)).enqueue(); + Test.stopTest(); + + List rows = [ + SELECT Status__c, StartedAt__c, FinishedAt__c, SalesforceJobId__c, NextAttemptAt__c + FROM AsyncResult__c + ]; + Assert.areEqual(2, rows.size(), 'The placeholder is updated, never duplicated.'); + for (AsyncResult__c row : rows) { + Assert.areEqual(QueueableManager.STATUS_COMPLETED, row.Status__c); + Assert.isNotNull(row.StartedAt__c); + Assert.isTrue(row.FinishedAt__c >= row.StartedAt__c); + Assert.isNotNull(row.SalesforceJobId__c); + Assert.isNull(row.NextAttemptAt__c); + } + } + + @IsTest + private static void shouldRecordRetryOnThePlaceholderRow() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + MarkerJob job = markerJob('F', true); + job.rollbackOnJobExecuteFail = true; + job.maxRetries = 1; + job.backoff = Backoff.fixed(2); + QueueableChain chain = new QueueableChain(); + chain.addJob(job); + QueueableManager.get().setChain(chain); + chain.enqueueNextJobIfAny(); + + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setResult(ParentJobResult.SUCCESS) + ); + + List rows = [ + SELECT Status__c, RetryAttempts__c, NextAttemptAt__c, RetryHistory__c, DelayMinutes__c + FROM AsyncResult__c + ]; + Assert.areEqual(1, rows.size()); + Assert.areEqual(QueueableManager.STATUS_ENQUEUED, rows[0].Status__c); + Assert.areEqual(1, rows[0].RetryAttempts__c); + Assert.isTrue(rows[0].NextAttemptAt__c > System.now().addMinutes(1), 'Due in 2 minutes.'); + Assert.isNull(rows[0].DelayMinutes__c, 'A backoff is not a delay the caller asked for.'); + Assert.isTrue(rows[0].RetryHistory__c.contains('Attempt 1')); + } + + @IsTest + private static void shouldKeepPlaceholderThroughRetryAndRecordAttempts() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + + Test.startTest(); + Async.queueable(markerJob('F', true)).retry(2).rollbackOnJobExecuteFail().enqueue(); + Test.stopTest(); + + List rows = [ + SELECT Status__c, RetryAttempts__c, NextAttemptAt__c + FROM AsyncResult__c + ]; + Assert.areEqual(1, rows.size(), 'One row for the job, across every attempt.'); + Assert.areEqual(QueueableManager.STATUS_FAILED, rows[0].Status__c); + Assert.areEqual(2, rows[0].RetryAttempts__c); + Assert.isNull(rows[0].NextAttemptAt__c, 'Cleared once the job settles.'); + } + + @IsTest + private static void shouldWriteJunctionRowsForEnqueueTimeLinksAtPlaceholderTime() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + Account linked = new Account(Name = 'Linked'); + insert linked; + + Test.startTest(); + Async.queueable(markerJob('A', false)).linkRecord(linked).enqueue(); + Integer linksBeforeRun = [ + SELECT COUNT() + FROM AsyncResultLink__c + WHERE Key__c = :linked.Id + ]; + Test.stopTest(); + + Assert.areEqual(1, linksBeforeRun, 'Linked while still enqueued.'); + List links = [ + SELECT ObjectName__c, AsyncResult__r.Status__c + FROM AsyncResultLink__c + WHERE Key__c = :linked.Id + ]; + Assert.areEqual(1, links.size(), 'The terminal write adds no second link.'); + Assert.areEqual('Account', links[0].ObjectName__c); + Assert.areEqual(QueueableManager.STATUS_COMPLETED, links[0].AsyncResult__r.Status__c); + } + + @IsTest + private static void shouldWriteJunctionRowsForRuntimeLinksAtTerminalTime() { + QueueableJobSettings.jobSettingByName = new Map(); + Account linked = new Account(Name = 'Linked'); + insert linked; + RecordLinkingJob job = new RecordLinkingJob(); + job.toLink = linked.Id; + + Test.startTest(); + Async.queueable(job).enqueue(); + Test.stopTest(); + + Assert.areEqual( + 1, + [SELECT COUNT() FROM AsyncResultLink__c WHERE Key__c = :linked.Id], + 'A runtime link forces the result row and lands with it.' + ); + } + + @IsTest + private static void shouldForceAResultRowForAJobWithRecordLinks() { + QueueableJobSettings.jobSettingByName = new Map(); + + Test.startTest(); + Async.queueable(markerJob('A', false)).linkRecord(fakeAccountId(1)).enqueue(); + Test.stopTest(); + + Assert.areEqual( + QueueableManager.STATUS_COMPLETED, + [SELECT Status__c FROM AsyncResult__c].Status__c, + 'Linking a job to a record is opting into a row for it.' + ); + } + + @IsTest + private static void shouldWriteAnExternalLinkAsGiven() { + QueueableJobSettings.jobSettingByName = new Map(); + + Test.startTest(); + Async.queueable(markerJob('A', false)) + .linkExternalRecord('NetSuite', 'Customer', '4711') + .linkExternalRecord(null, null, '4711') + .enqueue(); + Test.stopTest(); + + List links = [ + SELECT Key__c, Source__c, ObjectName__c, UniqueKey__c, AsyncResult__c + FROM AsyncResultLink__c + ORDER BY CreatedDate, Id + ]; + Assert.areEqual(2, links.size(), 'Same key under another source is another link.'); + Assert.areEqual('NetSuite', links[0].Source__c); + Assert.areEqual('Customer', links[0].ObjectName__c); + Assert.areEqual('4711', links[0].Key__c); + Assert.isNull(links[1].Source__c, 'Nothing given, nothing stamped.'); + Assert.isNull(links[1].ObjectName__c); + Assert.areEqual(59, links[0].UniqueKey__c.length(), 'Result id, colon, SHA1 hex.'); + Assert.isTrue(links[0].UniqueKey__c.startsWith(links[0].AsyncResult__c + ':')); + } + + @IsTest + private static void shouldCarryTheStoredLinksIntoTheReplay() { + QueueableChain capture = chainStoringPayloads(); + RequeueableJob job = new RequeueableJob('ONE'); + capture.addJob(job); + AsyncResult__c row = storedRowInChain(job, 1, null); + insert new List{ + new AsyncResultLink__c( + AsyncResult__c = row.Id, + Key__c = fakeAccountId(1), + Source__c = QueueableManager.SOURCE_SALESFORCE, + ObjectName__c = 'Account', + UniqueKey__c = row.Id + ':a' + ), + new AsyncResultLink__c( + AsyncResult__c = row.Id, + Key__c = '4711', + Source__c = 'NetSuite', + ObjectName__c = 'Customer', + UniqueKey__c = row.Id + ':b' + ) + }; + + Async.RequeueSummary summary = Async.requeue(new Set{ row.Id }); + + QueueableJob replay = summary.enqueueResult.queueableChainState.jobs[0]; + Assert.areEqual( + new List{ fakeAccountId(1), '4711' }, + keysOf(replay.links), + 'Links come from the stored rows, so runtime links replay too.' + ); + Assert.areEqual( + new List{ QueueableManager.SOURCE_SALESFORCE, 'NetSuite' }, + sourcesOf(replay.links) + ); + } + + @IsTest + private static void shouldLinkARecordWhosePrefixHasNoApexType() { + QueueableJobSettings.jobSettingByName = new Map(); + Id noApexType = Id.valueOf( + CaseComment.SObjectType.getDescribe().getKeyPrefix() + '1'.leftPad(12, '0') + ); + + Test.startTest(); + Async.queueable(markerJob('A', false)).linkRecord(noApexType).enqueue(); + Test.stopTest(); + + AsyncResultLink__c link = [ + SELECT ObjectName__c + FROM AsyncResultLink__c + WHERE Key__c = :noApexType + ]; + Assert.isNull(link.ObjectName__c, 'No type to name, the link is still written.'); + Assert.areEqual(1, accountCount('A'), 'The chain was not harmed.'); + } + + @IsTest + private static void shouldTruncateRecordLinksAtTheConfiguredMaximumAndWarn() { + Map settings = resultsEnabledForAll(); + settings.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL).MaxRecordLinks__c = 3; + QueueableJobSettings.jobSettingByName = settings; + MarkerJob job = markerJob('A', false); + + Test.startTest(); + Async.queueable(job) + .linkRecords( + new List{ + fakeAccountId(1), + fakeAccountId(2), + fakeAccountId(3), + fakeAccountId(4), + fakeAccountId(5) + } + ) + .enqueue(); + Test.stopTest(); + + Assert.areEqual(3, [SELECT COUNT() FROM AsyncResultLink__c]); + Assert.isTrue( + [SELECT RetryHistory__c FROM AsyncResult__c] + .RetryHistory__c.contains( + String.format( + QueueableManager.WARNING_RECORD_LINKS_TRUNCATED, + new List{ '3', job.className, '5' } + ) + ) + ); + } + + @IsTest + private static void shouldStampChainNameFromTheLastJobThatSetIt() { + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); + + Test.startTest(); + Async.queueable(markerJob('A', false)) + .chainName('First') + .chain(markerJob('B', false)) + .chain(markerJob('C', false)) + .chainName('Last') + .enqueue(); + Test.stopTest(); + + for (AsyncResult__c row : [SELECT ChainName__c FROM AsyncResult__c]) { + Assert.areEqual('Last', row.ChainName__c); + } + } + + @IsTest + private static void shouldRecordChunkRunIdAndPageNumbersOnPageRows() { + List accounts = createAccounts(6); + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); + + Test.startTest(); + Async.chunk(new OrderMarkerChunkJob(), ChunkSource.of(accounts)).chunkSize(2).enqueue(); + Test.stopTest(); + + List pages = [ + SELECT ChunkRunId__c, ChunkPage__c, ChunkPages__c, CustomJobId__c + FROM AsyncResult__c + ORDER BY ChunkPage__c + ]; + Assert.areEqual(3, pages.size()); + Assert.areEqual( + pages[0].CustomJobId__c, + pages[0].ChunkRunId__c, + 'The run id is the first page.' + ); + for (Integer i = 0; i < 3; i++) { + Assert.areEqual(pages[0].ChunkRunId__c, pages[i].ChunkRunId__c); + Assert.areEqual(i + 1, pages[i].ChunkPage__c); + Assert.areEqual(3, pages[i].ChunkPages__c); + } + } + + @IsTest + private static void shouldNumberAPrioritizedJobInTheOrderItRuns() { + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); + + Test.startTest(); + Async.queueable(orderMarker('A')).chain(); + Async.queueable(orderMarker('B')).chain(); + Async.queueable(orderMarker('C')).priority(1).enqueue(); + Test.stopTest(); + + Assert.areEqual(new List{ 'C', 'A', 'B' }, runOrder); + Map rowByName = rowsByJobName(); + Assert.areEqual(1, rowByName.get('C').ChainSequence__c); + Assert.areEqual(2, rowByName.get('A').ChainSequence__c); + Assert.areEqual(3, rowByName.get('B').ChainSequence__c); + } + + @IsTest + private static void shouldNumberARuntimePriorityJobRightAfterTheRunningOne() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + priorityChainByTag = new Map{ 'A' => 'P' }; + sequenceSnapshotTag = 'P'; + + Test.startTest(); + Async.queueable(orderMarker('A')).chain(orderMarker('B')).chain(orderMarker('C')).enqueue(); + Test.stopTest(); + + Assert.areEqual(new List{ 'A', 'P', 'B', 'C' }, runOrder); + Assert.areEqual(3, placeholderSequenceSeen.get('B'), 'B\'s placeholder moved behind P.'); + Assert.areEqual(4, placeholderSequenceSeen.get('C')); + Map rowByName = rowsByJobName(); + Assert.areEqual(1, rowByName.get('A').ChainSequence__c, 'The running job holds its place.'); + Assert.areEqual(2, rowByName.get('P').ChainSequence__c); + } + + @IsTest + private static void shouldNumberNextInChainAheadOfARuntimePriorityJob() { + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); + insertsByTag = new Map>{ 'A' => new List{ 'X' } }; + priorityChainByTag = new Map{ 'A' => 'P' }; + + Test.startTest(); + Async.queueable(orderMarker('A')).chain(orderMarker('B')).enqueue(); + Test.stopTest(); + + Assert.areEqual(new List{ 'A', 'X', 'P', 'B' }, runOrder); + Map rowByName = rowsByJobName(); + Assert.areEqual(1, rowByName.get('A').ChainSequence__c); + Assert.areEqual(2, rowByName.get('X').ChainSequence__c); + Assert.areEqual(3, rowByName.get('P').ChainSequence__c); + Assert.areEqual(4, rowByName.get('B').ChainSequence__c); + } + + @IsTest + private static void shouldKeepAChunkRunInOneSlotWhenAPriorityJobArrivesBetweenPages() { + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); + chunkSourceRecords = createAccounts(6); + priorityChainByTag = new Map{ 'page1' => 'P' }; + + Test.startTest(); + Async.chunk(new OrderMarkerChunkJob(), ChunkSource.of(chunkSourceRecords)) + .chunkSize(2) + .chain(orderMarker('B')) + .enqueue(); + Test.stopTest(); + + Assert.areEqual(new List{ 'page1', 'P', 'page2', 'page3', 'B' }, runOrder); + for (AsyncResult__c page : [ + SELECT ChainSequence__c + FROM AsyncResult__c + WHERE ChunkPage__c != NULL + ]) { + Assert.areEqual(1, page.ChainSequence__c, 'Every page keeps the run slot.'); + } + Map rowByName = rowsByJobName(); + Assert.areEqual(2, rowByName.get('P').ChainSequence__c); + Assert.areEqual(3, rowByName.get('B').ChainSequence__c); + } + + @IsTest + private static void shouldRecordWhichRunningJobAddedAPlainChainedJob() { + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); + + Test.startTest(); + Async.queueable(new PlainChainingJob()).name('A').chain(orderMarker('B')).enqueue(); + Test.stopTest(); + + Assert.areEqual(new List{ 'B', 'Y' }, runOrder); + Map rowByName = rowsByJobName(); + Assert.areEqual( + rowByName.get('A').CustomJobId__c, + rowByName.get('Y').AddedByCustomJobId__c + ); + Assert.areEqual(3, rowByName.get('Y').ChainSequence__c, 'A plain chain() appends.'); + Assert.isNull( + rowByName.get('B').AddedByCustomJobId__c, + 'Nothing was running when B was chained.' + ); + } + + @IsTest + private static void shouldStopAtTheCapAndResumeAfterTheLastHandledEvent() { + resultsAndLoggerForBatches(null); + Integer cap = BatchFailureRecorder.MAX_EVENTS_PER_DELIVERY; + List events = batchFailureEvents(cap + 1); + + BatchFailureRecorder.record(events); + + Assert.areEqual(cap, [SELECT COUNT() FROM AsyncResult__c]); + Assert.areEqual( + events[cap - 1].ReplayId, + BatchFailureRecorder.resumeCheckpoint, + 'The rest of the delivery has to come back.' + ); + } + + @IsTest + private static void shouldNotAskForRedeliveryWhenTheWholeDeliveryFits() { + resultsAndLoggerForBatches(null); + Integer cap = BatchFailureRecorder.MAX_EVENTS_PER_DELIVERY; + + BatchFailureRecorder.record(batchFailureEvents(cap)); + + Assert.areEqual(cap, [SELECT COUNT() FROM AsyncResult__c]); + Assert.isNull(BatchFailureRecorder.resumeCheckpoint); + } + + @IsTest + private static void shouldKeepTheRecordsWhenTheLoggerSpendsADmlOnEveryFailure() { + resultsAndLoggerForBatches(loggerName('DmlBatchFailureLogger')); + + BatchFailureRecorder.record(batchFailureEvents(200)); + + Assert.areEqual( + BatchFailureRecorder.MAX_EVENTS_PER_DELIVERY, + [ + SELECT COUNT() + FROM AsyncResult__c + ] + ); + } + + @IsTest + private static void shouldKeepTheDelayOnTheRowAndTheTimeOnlyOnceTheJobIsEnqueued() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + + Test.startTest(); + Async.queueable(orderMarker('A')).delay(5).chain(orderMarker('B')).delay(10).enqueue(); + Map rowByName = delayRowsByJobName(); + Test.stopTest(); + + Assert.areEqual(5, rowByName.get('A').DelayMinutes__c); + Assert.isTrue( + rowByName.get('A').NextAttemptAt__c > System.now().addMinutes(4), + 'A is enqueued, so its start is known.' + ); + Assert.areEqual(10, rowByName.get('B').DelayMinutes__c); + Assert.isNull( + rowByName.get('B').NextAttemptAt__c, + 'B waits for A to finish, so its start is not known yet.' + ); + AsyncResult__c settled = delayRowsByJobName().get('A'); + Assert.areEqual(5, settled.DelayMinutes__c, 'Details still say what was asked for.'); + Assert.isNull(settled.NextAttemptAt__c); + } + + @IsTest + private static void shouldTreatADelayInAsyncOptionsLikeAnyOtherDelay() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + AsyncOptions options = new AsyncOptions(); + options.MinimumQueueableDelayInMinutes = 3; + + Test.startTest(); + Async.queueable(orderMarker('A')).asyncOptions(options).enqueue(); + AsyncResult__c row = delayRowsByJobName().get('A'); + Test.stopTest(); + + Assert.areEqual(3, row.DelayMinutes__c); + Assert.isNotNull(row.NextAttemptAt__c); + } + + @IsTest + private static void shouldNotSetAStartTimeForAJobWithoutADelay() { + QueueableJobSettings.jobSettingByName = trackProgressForAll(); + + Test.startTest(); + Async.queueable(orderMarker('A')).enqueue(); + AsyncResult__c row = delayRowsByJobName().get('A'); + Test.stopTest(); + + Assert.isNull(row.DelayMinutes__c); + Assert.isNull(row.NextAttemptAt__c); + } + + @IsTest + private static void shouldRecordParentCustomJobIdOnFinalizerRows() { + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); + + Test.startTest(); + Async.queueable(new FinalizerAttachingJob()).enqueue(); + Test.stopTest(); + + AsyncResult__c parent = [ + SELECT CustomJobId__c + FROM AsyncResult__c + WHERE ParentCustomJobId__c = NULL + ]; + AsyncResult__c finalizer = [ + SELECT ParentCustomJobId__c, AddedByCustomJobId__c + FROM AsyncResult__c + WHERE ParentCustomJobId__c != NULL + ]; + Assert.areEqual(parent.CustomJobId__c, finalizer.ParentCustomJobId__c); + Assert.isNull(finalizer.AddedByCustomJobId__c, 'A finalizer already names its parent.'); + } + + @IsTest + private static void shouldRecordAddedByAndSequenceOnNextInChainRows() { + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); + insertsByTag = new Map>{ 'A' => new List{ 'X' } }; + + Test.startTest(); + Async.queueable(orderMarker('A')).priority(3).chain(orderMarker('B')).enqueue(); + Test.stopTest(); + + Map rowByName = new Map(); + for (AsyncResult__c row : [ + SELECT JobName__c, CustomJobId__c, AddedByCustomJobId__c, ChainSequence__c, Priority__c + FROM AsyncResult__c + ]) { + rowByName.put(row.JobName__c, row); + } + Assert.areEqual( + rowByName.get('A').CustomJobId__c, + rowByName.get('X').AddedByCustomJobId__c + ); + Assert.isNull(rowByName.get('B').AddedByCustomJobId__c); + Assert.areEqual(1, rowByName.get('A').ChainSequence__c); + Assert.areEqual(2, rowByName.get('X').ChainSequence__c); + Assert.areEqual(3, rowByName.get('B').ChainSequence__c, 'Shifted to make room.'); + Assert.areEqual(3, rowByName.get('A').Priority__c); + } + + @IsTest + private static void shouldStoreDependenciesJsonOnTheRow() { + QueueableJobSettings.jobSettingByName = resultsEnabledForAll(); + + Test.startTest(); + Async.Result first = Async.queueable(markerJob('A', false)).chain(); + Async.queueable(markerJob('B', false)).dependsOn(Async.after(first).succeeded()).enqueue(); + Test.stopTest(); + + AsyncResult__c dependent = [ + SELECT Dependencies__c + FROM AsyncResult__c + WHERE CustomJobId__c != :first.customJobId + ]; + List stored = (List) JSON.deserialize( + dependent.Dependencies__c, + List.class + ); + Assert.areEqual(1, stored.size()); + Assert.areEqual(first.customJobId, stored[0].resolvedTargetCustomJobId); + Assert.areEqual(Async.Outcome.SUCCESS, stored[0].requiredOutcome); + } + + @IsTest + private static void shouldNotWriteAnythingNewWhenNothingIsOptedIn() { + QueueableJobSettings.jobSettingByName = new Map(); + + Test.startTest(); + Async.queueable(markerJob('A', false)) + .name('Named anyway') + .chain(markerJob('B', false)) + .enqueue(); + Test.stopTest(); + + Assert.areEqual(0, [SELECT COUNT() FROM AsyncResult__c], 'Names alone write nothing.'); + Assert.areEqual(0, [SELECT COUNT() FROM AsyncResultLink__c]); + } + + @IsTest + private static void shouldRequeueRowsInChainOrderRegardlessOfInputOrder() { + QueueableChain capture = chainStoringPayloads(); + RequeueableJob one = new RequeueableJob('ONE'); + RequeueableJob two = new RequeueableJob('TWO'); + RequeueableJob three = new RequeueableJob('THREE'); + capture.addJob(one); + capture.addJob(two); + capture.addJob(three); + AsyncResult__c rowThree = storedRowInChain(three, 3, null); + AsyncResult__c rowOne = storedRowInChain(one, 1, null); + AsyncResult__c rowTwo = storedRowInChain(two, 2, null); + + Async.RequeueSummary summary = Async.requeue( + new Set{ rowThree.Id, rowOne.Id, rowTwo.Id } + ); + + Assert.areEqual( + new List{ 'ONE', 'TWO', 'THREE' }, + tagsOf(summary.enqueueResult.queueableChainState.jobs), + 'Replays run in the order the original chain had.' + ); + } + + @IsTest + private static void shouldRebuildDependencyBetweenRequeuedRows() { + QueueableChain capture = chainStoringPayloads(); + RequeueableJob a = new RequeueableJob('A'); + RequeueableJob b = new RequeueableJob('B'); + capture.addJob(a); + capture.addJob(b); + AsyncResult__c rowA = storedRowInChain(a, 1, null); + AsyncResult__c rowB = storedRowInChain( + b, + 2, + new List{ dependencyOn(a.customJobId, Async.Outcome.SUCCESS) } + ); + + Test.startTest(); + Async.RequeueSummary summary = Async.requeue(new Set{ rowA.Id, rowB.Id }); + Test.stopTest(); + + List replay = summary.enqueueResult.queueableChainState.jobs; + Assert.areEqual(2, replay.size()); + Assert.areEqual( + replay[0].customJobId, + replay[1].dependencies[0].resolvedTargetCustomJobId, + 'B now depends on the replayed A.' + ); + Assert.areEqual(Async.Outcome.SUCCESS, replay[1].dependencies[0].requiredOutcome); + Assert.isTrue(summary.droppedDependencyByResultId.isEmpty()); + Assert.areEqual(1, accountCount('A')); + Assert.areEqual(1, accountCount('B'), 'A succeeded, so B ran.'); + } + + @IsTest + private static void shouldSkipDependentAgainWhenTheReplayedBlockerFailsAgain() { + QueueableChain capture = chainStoringPayloads(); + QueueableJobSettings.jobSettingByName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL) + .CreateResult__c = true; + FailingRequeueableJob a = new FailingRequeueableJob(); + a.continueOnJobExecuteFail = true; + RequeueableJob b = new RequeueableJob('B'); + capture.addJob(a); + capture.addJob(b); + AsyncResult__c rowA = storedRowInChain(a, 1, null); + AsyncResult__c rowB = storedRowInChain( + b, + 2, + new List{ dependencyOn(a.customJobId, Async.Outcome.SUCCESS) } + ); + + Test.startTest(); + Async.requeue(new Set{ rowA.Id, rowB.Id }); + Test.stopTest(); + + Assert.areEqual(0, accountCount('B'), 'A failed again, so B was skipped again.'); + Assert.areEqual( + QueueableManager.STATUS_SKIPPED_DEPENDENCY, + [SELECT Status__c FROM AsyncResult__c WHERE RequeuedFrom__c = :rowB.Id].Status__c + ); + } + + @IsTest + private static void shouldDropDependencyOnAJobOutsideTheSetThatSatisfiedIt() { + QueueableChain capture = chainStoringPayloads(); + RequeueableJob b = new RequeueableJob('B'); + capture.addJob(b); + insert new AsyncResult__c( + ClassName__c = 'CreateInvoiceJob', + CustomJobId__c = 'a-id', + ChainId__c = b.chainId, + ChainSequence__c = 1, + JobName__c = 'Create invoice', + Status__c = QueueableManager.STATUS_COMPLETED + ); + AsyncResult__c rowB = storedRowInChain( + b, + 2, + new List{ dependencyOn('a-id', Async.Outcome.COMPLETED) } + ); + + Async.RequeueSummary summary = Async.requeue(rowB.Id); + + Assert.areEqual(new List{ rowB.Id }, summary.requeued); + Assert.areEqual( + String.format( + QueueableManager.REQUEUE_DEPENDENCY_DROPPED, + new List{ 'Create invoice', 'SUCCESS' } + ), + summary.droppedDependencyByResultId.get(rowB.Id) + ); + Assert.isNull(summary.enqueueResult.queueableChainState.jobs[0].dependencies); + } + + @IsTest + private static void shouldSkipRowWhoseDependencyOutsideTheSetWasNotMet() { + QueueableChain capture = chainStoringPayloads(); + RequeueableJob b = new RequeueableJob('B'); + capture.addJob(b); + insert new AsyncResult__c( + ClassName__c = 'CreateInvoiceJob', + CustomJobId__c = 'a-id', + ChainId__c = b.chainId, + ChainSequence__c = 1, + JobName__c = 'Create invoice', + Status__c = QueueableManager.STATUS_FAILED + ); + AsyncResult__c rowB = storedRowInChain( + b, + 2, + new List{ dependencyOn('a-id', Async.Outcome.SUCCESS) } + ); + + Async.RequeueSummary summary = Async.requeue(rowB.Id); + + Assert.isTrue(summary.requeued.isEmpty(), 'Nothing runs without its blocker.'); + Assert.areEqual( + String.format( + QueueableManager.REQUEUE_SKIPPED_DEPENDENCY_UNMET, + new List{ 'Create invoice', 'SUCCESS', 'FAILURE' } + ), + summary.skipReasonByResultId.get(rowB.Id) + ); + Assert.isNull(summary.enqueueResult); + Assert.areEqual( + AsyncRequeue.STATUS_STORED, + [SELECT RequeueStatus__c FROM AsyncResult__c WHERE Id = :rowB.Id].RequeueStatus__c, + 'A skipped row stays replayable.' + ); + } + + @IsTest + private static void shouldSkipRowWhoseDependencyTargetRowIsGone() { + QueueableChain capture = chainStoringPayloads(); + RequeueableJob b = new RequeueableJob('B'); + capture.addJob(b); + AsyncResult__c rowB = storedRowInChain( + b, + 2, + new List{ dependencyOn('gone-id', Async.Outcome.SUCCESS) } + ); + + Async.RequeueSummary summary = Async.requeue(rowB.Id); + + Assert.isTrue(summary.requeued.isEmpty()); + Assert.areEqual( + String.format( + QueueableManager.REQUEUE_SKIPPED_DEPENDENCY_UNKNOWN, + new List{ 'gone-id' } + ), + summary.skipReasonByResultId.get(rowB.Id) + ); + } + + private static AsyncResult__c storedRowInChain( + QueueableJob capturedJob, + Integer sequence, + List dependencies + ) { + AsyncResult__c stored = new AsyncResult__c( + ClassName__c = capturedJob.className, + CustomJobId__c = capturedJob.customJobId, + ChainId__c = capturedJob.chainId, + ChainSequence__c = sequence, + JobName__c = capturedJob.jobName, + Status__c = QueueableManager.STATUS_FAILED, + JobPayload__c = capturedJob.requeuePayload, + PayloadSize__c = capturedJob.requeuePayloadSize, + RequeueStatus__c = AsyncRequeue.STATUS_STORED, + Dependencies__c = dependencies == null ? null : JSON.serialize(dependencies) + ); + insert stored; + return stored; + } + + private static List tagsOf(List jobs) { + List tags = new List(); + for (QueueableJob job : jobs) { + tags.add(((RequeueableJob) job).tag); + } + return tags; + } + + private static Map trackProgressForAll() { + return new Map{ + QueueableManager.QUEUEABLE_JOB_SETTING_ALL => new QueueableJobSetting__mdt( + DeveloperName = QueueableManager.QUEUEABLE_JOB_SETTING_ALL, + QueueableJobName__c = QueueableManager.QUEUEABLE_JOB_SETTING_ALL, + TrackJobProgress__c = 'Yes' + ) + }; + } + + private static void insertNextInChainFor(String tag) { + for (String next : insertsByTag.get(tag) ?? new List()) { + Async.queueable(orderMarker(next)).nextInChain().enqueue(); + } + String prioritized = priorityChainByTag.get(tag); + if (prioritized != null) { + Async.queueable(orderMarker(prioritized)).priority(1).enqueue(); + } + } + + private static Map rowsByJobName() { + Map rowByName = new Map(); + for (AsyncResult__c row : [ + SELECT JobName__c, CustomJobId__c, AddedByCustomJobId__c, ChainSequence__c + FROM AsyncResult__c + ]) { + rowByName.put(row.JobName__c, row); + } + return rowByName; + } + + private static Map delayRowsByJobName() { + Map rowByName = new Map(); + for (AsyncResult__c row : [ + SELECT JobName__c, DelayMinutes__c, NextAttemptAt__c + FROM AsyncResult__c + ]) { + rowByName.put(row.JobName__c, row); + } + return rowByName; + } + + private static List batchFailureEvents(Integer count) { + List events = new List(); + for (Integer i = 0; i < count; i++) { + events.add(batchFailureEvent('System.DmlException')); + } + return events; + } + + private static OrderMarkerJob orderMarker(String tag) { + OrderMarkerJob job = new OrderMarkerJob(); + job.tag = tag; + job.jobName = tag; + return job; + } + + private static List chainSequencesOf(List jobs) { + List sequences = new List(); + for (QueueableJob job : jobs) { + sequences.add(job.chainSequence); + } + return sequences; + } + + private static Id fakeAccountId(Integer index) { + return Id.valueOf('001' + String.valueOf(index).leftPad(12, '0')); + } + private static String loggerName(String simpleName) { return getClassNameWithNamespaceDotPrefix('AsyncTest.' + simpleName); } @@ -6835,7 +8466,7 @@ private class AsyncTest implements Database.Batchable { private static QueueableChain chainWithSettings(QueueableJobSetting__mdt setting) { QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = new Map{ + QueueableJobSettings.jobSettingByName = new Map{ setting.QueueableJobName__c => setting }; return chain; @@ -6911,7 +8542,7 @@ private class AsyncTest implements Database.Batchable { private static QueueableChain chainStoringPayloads() { QueueableChain chain = new QueueableChain(); - QueueableChain.jobSettingByName = payloadStorageOn(); + QueueableJobSettings.jobSettingByName = payloadStorageOn(); return chain; } @@ -7027,6 +8658,12 @@ private class AsyncTest implements Database.Batchable { } } + public class DmlBatchFailureLogger implements Async.OnJobFailed { + public void onJobFailed(Async.FailureContext ctx) { + insert new Account(Name = 'Logged failure'); + } + } + public class ThrowingBatchFailureLogger implements Async.OnJobFailed { public void onJobFailed(Async.FailureContext ctx) { throw new CustomException('logger exploded'); @@ -7118,6 +8755,95 @@ private class AsyncTest implements Database.Batchable { } } + private class PlainChainingJob extends QueueableJob { + public override void work() { + Async.queueable(orderMarker('Y')).chain(); + } + } + + private class OrderMarkerJob extends QueueableJob { + public String tag; + public override void work() { + AsyncTest.runOrder.add(tag); + AsyncTest.insertNextInChainFor(tag); + if (tag == AsyncTest.sequenceSnapshotTag) { + for (AsyncResult__c row : [ + SELECT JobName__c, ChainSequence__c + FROM AsyncResult__c + WHERE Status__c = :QueueableManager.STATUS_ENQUEUED + ]) { + AsyncTest.placeholderSequenceSeen.put( + row.JobName__c, + row.ChainSequence__c.intValue() + ); + } + } + } + } + + private class OrderMarkerChunkJob extends ChunkJob implements Async.ChunkResettable { + public override void work(List page) { + String tag = 'page' + getRun().currentPageNumber(); + AsyncTest.runOrder.add(tag); + AsyncTest.insertNextInChainFor(tag); + } + + public void resetBeforeNextChunk(Integer pageNumber) { + } + } + + private class ChunkInsertingJob extends QueueableJob { + public override void work() { + AsyncTest.runOrder.add('A'); + Async.chunk(new OrderMarkerChunkJob(), ChunkSource.of(AsyncTest.chunkSourceRecords)) + .chunkSize(2) + .nextInChain() + .enqueue(); + } + } + + private class FinalizerAttachingJob extends QueueableJob { + public override void work() { + Async.queueable(new TaggingFinalizer()).attachFinalizer(); + } + } + + private class TaggingFinalizer extends QueueableJob.Finalizer { + public override void work() { + AsyncTest.runOrder.add('finalizer'); + } + } + + private class EveryLinkFormJob extends QueueableJob { + public override void work() { + Async.linkRecord(new Account(Id = fakeAccountId(1))); + Async.linkRecords(new List{ new Account(Id = fakeAccountId(2)) }); + Async.linkRecords(new List{ fakeAccountId(3) }); + Async.linkRecords(new Set{ fakeAccountId(4) }); + Async.linkExternalRecord('NetSuite', 'Customer', '4711'); + Async.linkExternalRecords( + new List{ new Async.RecordLink('SAP', 'Order', '42') } + ); + } + } + + private class RecordLinkingJob extends QueueableJob { + public Id toLink; + public override void work() { + Async.linkRecord(toLink); + AsyncTest.dmlStatementsAfterLink = Limits.getDmlStatements(); + } + } + + private class ContextCapturingJob extends QueueableJob implements Async.OnJobEnqueued { + public override void work() { + } + + public void onJobEnqueued(Async.JobContext ctx) { + AsyncTest.capturedJobContext = ctx; + } + } + private class StopChainFinalizer extends QueueableJob.Finalizer { public override void work() { Async.stopChain(); diff --git a/force-app/main/default/classes/batch/BatchFailureRecorder.cls b/force-app/main/default/classes/batch/BatchFailureRecorder.cls index 6b2d204..c6cb722 100644 --- a/force-app/main/default/classes/batch/BatchFailureRecorder.cls +++ b/force-app/main/default/classes/batch/BatchFailureRecorder.cls @@ -11,6 +11,10 @@ @SuppressWarnings('PMD.AvoidDebugStatements') public inherited sharing class BatchFailureRecorder { public static final String PHASE_EXECUTE = 'EXECUTE'; + public static final Integer MAX_EVENTS_PER_DELIVERY = 50; + + @TestVisible + private static String resumeCheckpoint; private static final Integer MAX_EXCEPTION_MESSAGE_LENGTH = 32768; private static final String RESULT_UNHANDLED_EXCEPTION = 'UNHANDLED_EXCEPTION'; @@ -18,12 +22,32 @@ public inherited sharing class BatchFailureRecorder { public static void record(List events) { try { - recordAndNotify(events); + List handledNow = firstEventsOf(events); + recordAndNotify(handledNow); + if (handledNow.size() < events.size()) { + resumeAfter(handledNow[handledNow.size() - 1]); + } } catch (Exception recordingFailure) { reportWithoutLosingTheOtherFailuresInThisDelivery(recordingFailure); } } + private static List firstEventsOf(List events) { + List first = new List(); + for (BatchApexErrorEvent event : events) { + if (first.size() == MAX_EVENTS_PER_DELIVERY) { + break; + } + first.add(event); + } + return first; + } + + private static void resumeAfter(BatchApexErrorEvent lastHandled) { + resumeCheckpoint = lastHandled.ReplayId; + EventBus.TriggerContext.currentContext().setResumeCheckpoint(resumeCheckpoint); + } + private static void reportWithoutLosingTheOtherFailuresInThisDelivery( Exception recordingFailure ) { @@ -107,7 +131,7 @@ public inherited sharing class BatchFailureRecorder { } private static Boolean createResultEnabledFor(String className) { - Map settings = QueueableChain.jobSettingByName; + Map settings = QueueableJobSettings.jobSettingByName; return settings.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL)?.CreateResult__c == true || settings.get(className)?.CreateResult__c == true; } diff --git a/force-app/main/default/classes/mocks/AsyncMock.cls b/force-app/main/default/classes/mocks/AsyncMock.cls index 6342d85..105edc6 100644 --- a/force-app/main/default/classes/mocks/AsyncMock.cls +++ b/force-app/main/default/classes/mocks/AsyncMock.cls @@ -16,7 +16,7 @@ public class AsyncMock { for (QueueableJobSetting__mdt setting : settings) { settingByJobName.put(setting.QueueableJobName__c, setting); } - QueueableChain.jobSettingByName = settingByJobName; + QueueableJobSettings.jobSettingByName = settingByJobName; } public static FinalizerMockSetup whenFinalizer(String mockId) { @@ -72,7 +72,7 @@ public class AsyncMock { queueableSetups.clear(); defaultFinalizerSetup = null; defaultQueueableSetup = null; - QueueableChain.jobSettingByName = null; + QueueableJobSettings.jobSettingByName = null; } public class FinalizerMockSetup { diff --git a/force-app/main/default/classes/queue/AsyncRequeue.cls b/force-app/main/default/classes/queue/AsyncRequeue.cls index daa4e53..46dca40 100644 --- a/force-app/main/default/classes/queue/AsyncRequeue.cls +++ b/force-app/main/default/classes/queue/AsyncRequeue.cls @@ -3,7 +3,11 @@ * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) * * v3.1.0 + * + * PMD False Positives: + * - CyclomaticComplexity: one guard clause per requeue rule, and the most complex member scores 7 **/ +@SuppressWarnings('PMD.CyclomaticComplexity') public inherited sharing class AsyncRequeue { public static final String STATUS_STORED = 'Stored'; public static final String STATUS_TOO_LARGE = 'TooLarge'; @@ -44,6 +48,10 @@ public inherited sharing class AsyncRequeue { job.requeuePayload = payload; } + public static void releasePayload(QueueableJob job) { + job.requeuePayload = null; + } + public static Boolean storesPayload(QueueableJob job) { return QueueableManager.settingFor( job.className, @@ -59,11 +67,11 @@ public inherited sharing class AsyncRequeue { List replayable = replayableOrExplained(resultIds, summary); assertPayloadsWillFitInHeap(replayable); - QueueableBuilder replay = chainRebuilt(readPayloads(replayable), summary); - if (summary.requeued.isEmpty()) { + QueueableJob lastChained = chainRebuilt(readPayloads(replayable), summary); + if (lastChained == null) { return summary; } - summary.enqueueResult = replay.enqueue(); + summary.enqueueResult = QueueableManager.get().enqueue(lastChained); markRequeued(summary.requeued); return summary; } @@ -87,21 +95,49 @@ public inherited sharing class AsyncRequeue { return replayable; } - private static QueueableBuilder chainRebuilt( - List lockedRows, + private static QueueableJob chainRebuilt( + List rows, Async.RequeueSummary summary ) { - QueueableBuilder replay = Async.queueable(); - for (AsyncResult__c row : lockedRows) { + rows.sort(new ChainOrder()); + AsyncRequeueDependencies dependencies = new AsyncRequeueDependencies(rows, summary); + Map> linksByResultId = storedLinksOf(rows); + QueueableJob lastChained; + for (AsyncResult__c row : rows) { QueueableJob job = rebuildOrExplain(row, summary); if (job == null) { continue; } + List rebuilt = dependencies.rebuiltFor(row); + if (rebuilt == null) { + continue; + } + job.dependencies = rebuilt.isEmpty() ? null : rebuilt; + job.addLinks(linksByResultId.get(row.Id)); job.requeuedFromResultId = row.Id; - replay.chain(job); + Async.Result chained = Async.queueable(job).chain(); + dependencies.remember(row.CustomJobId__c, chained.customJobId); summary.requeued.add(row.Id); + lastChained = chained.job; + } + return lastChained; + } + + private static Map> storedLinksOf(List rows) { + Map> linksByResultId = new Map>(); + for (AsyncResultLink__c stored : [ + SELECT AsyncResult__c, Source__c, ObjectName__c, Key__c + FROM AsyncResultLink__c + WHERE AsyncResult__c IN :rows + WITH SYSTEM_MODE + ORDER BY CreatedDate, Id + ]) { + List links = linksByResultId.get(stored.AsyncResult__c) ?? + new List(); + links.add(new Async.RecordLink(stored.Source__c, stored.ObjectName__c, stored.Key__c)); + linksByResultId.put(stored.AsyncResult__c, links); } - return replay; + return linksByResultId; } private static void recordNotSerializable(QueueableJob job, String cause) { @@ -126,13 +162,39 @@ public inherited sharing class AsyncRequeue { private static List readPayloads(List lockedRows) { return [ - SELECT Id, ClassName__c, JobPayload__c + SELECT + Id, + ClassName__c, + JobPayload__c, + ChainId__c, + ChainSequence__c, + CustomJobId__c, + Dependencies__c, + CreatedDate FROM AsyncResult__c WHERE Id IN :lockedRows WITH SYSTEM_MODE ]; } + private class ChainOrder implements Comparator { + public Integer compare(AsyncResult__c first, AsyncResult__c second) { + Integer byChain = (first.ChainId__c ?? '').compareTo(second.ChainId__c ?? ''); + if (byChain != 0) { + return byChain; + } + Decimal firstSequence = first.ChainSequence__c ?? Decimal.valueOf(2147483647L); + Decimal secondSequence = second.ChainSequence__c ?? Decimal.valueOf(2147483647L); + if (firstSequence != secondSequence) { + return firstSequence < secondSequence ? -1 : 1; + } + if (first.CreatedDate == second.CreatedDate) { + return 0; + } + return first.CreatedDate < second.CreatedDate ? -1 : 1; + } + } + private static Set missingIds( Set requestedIds, Async.RequeueSummary summary, diff --git a/force-app/main/default/classes/queue/AsyncRequeueDependencies.cls b/force-app/main/default/classes/queue/AsyncRequeueDependencies.cls new file mode 100644 index 0000000..ef56e50 --- /dev/null +++ b/force-app/main/default/classes/queue/AsyncRequeueDependencies.cls @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public inherited sharing class AsyncRequeueDependencies { + private final Async.RequeueSummary summary; + private final Map newCustomJobIdByOld = new Map(); + private final Map targetByKey; + + public AsyncRequeueDependencies(List rows, Async.RequeueSummary summary) { + this.summary = summary; + this.targetByKey = targetsOf(rows); + } + + public void remember(String oldCustomJobId, String newCustomJobId) { + newCustomJobIdByOld.put(oldCustomJobId, newCustomJobId); + } + + public List rebuiltFor(AsyncResult__c row) { + List rebuilt = new List(); + for (Async.Dependency dependency : storedDependenciesOf(row)) { + String replayTarget = newCustomJobIdByOld.get(dependency.resolvedTargetCustomJobId); + if (replayTarget != null) { + dependency.resolvedTargetCustomJobId = replayTarget; + rebuilt.add(dependency); + } else if (!resolvedAgainstHistory(row, dependency)) { + return null; + } + } + return rebuilt; + } + + private Boolean resolvedAgainstHistory(AsyncResult__c row, Async.Dependency dependency) { + String oldTargetId = dependency.resolvedTargetCustomJobId; + AsyncResult__c target = targetByKey.get(targetKey(row.ChainId__c, oldTargetId)); + if (target == null) { + skip( + row, + QueueableManager.REQUEUE_SKIPPED_DEPENDENCY_UNKNOWN, + new List{ oldTargetId } + ); + return false; + } + Async.Outcome actual = QueueableManager.outcomeOf(target.Status__c); + String label = target.JobName__c ?? target.ClassName__c; + if (!QueueableManager.outcomeMatches(actual, dependency.requiredOutcome)) { + skip( + row, + QueueableManager.REQUEUE_SKIPPED_DEPENDENCY_UNMET, + new List{ + label, + dependency.requiredOutcome.name(), + actual?.name() ?? 'NOT_RUN' + } + ); + return false; + } + noteDropped( + row.Id, + String.format( + QueueableManager.REQUEUE_DEPENDENCY_DROPPED, + new List{ label, actual.name() } + ) + ); + return true; + } + + private void skip(AsyncResult__c row, String message, List arguments) { + summary.skipReasonByResultId.put(row.Id, String.format(message, arguments)); + } + + private void noteDropped(Id resultId, String note) { + String earlier = summary.droppedDependencyByResultId.get(resultId); + summary.droppedDependencyByResultId.put( + resultId, + earlier == null ? note : earlier + '\n' + note + ); + } + + public static List storedDependenciesOf(AsyncResult__c row) { + return String.isBlank(row.Dependencies__c) + ? new List() + : (List) JSON.deserialize( + row.Dependencies__c, + List.class + ); + } + + private static Map targetsOf(List rows) { + Set chainIds = new Set(); + Set targetIds = new Set(); + for (AsyncResult__c row : rows) { + for (Async.Dependency dependency : storedDependenciesOf(row)) { + chainIds.add(row.ChainId__c); + targetIds.add(dependency.resolvedTargetCustomJobId); + } + } + Map targetByKey = new Map(); + if (targetIds.isEmpty()) { + return targetByKey; + } + for (AsyncResult__c target : [ + SELECT ChainId__c, CustomJobId__c, Status__c, JobName__c, ClassName__c + FROM AsyncResult__c + WHERE ChainId__c IN :chainIds AND CustomJobId__c IN :targetIds + WITH SYSTEM_MODE + ]) { + targetByKey.put(targetKey(target.ChainId__c, target.CustomJobId__c), target); + } + return targetByKey; + } + + private static String targetKey(String chainId, String customJobId) { + return chainId + '|' + customJobId; + } +} diff --git a/force-app/main/default/classes/queue/AsyncRequeueDependencies.cls-meta.xml b/force-app/main/default/classes/queue/AsyncRequeueDependencies.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/force-app/main/default/classes/queue/AsyncRequeueDependencies.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/force-app/main/default/classes/queue/AsyncResultWriter.cls b/force-app/main/default/classes/queue/AsyncResultWriter.cls new file mode 100644 index 0000000..818a87c --- /dev/null +++ b/force-app/main/default/classes/queue/AsyncResultWriter.cls @@ -0,0 +1,515 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + * + * PMD False Positives: + * - CognitiveComplexity / CyclomaticComplexity: one guard clause per write path, and the most + * complex member scores 8 + * - AvoidDebugStatements: when the framework failure row cannot be written either, the debug log + * is the only channel left + **/ +@SuppressWarnings('PMD.CognitiveComplexity,PMD.CyclomaticComplexity') +public inherited sharing class AsyncResultWriter { + @TestVisible + private static Integer dmlRowReserve = 200; + private Map resultIdByCustomJobId = new Map(); + private Set resequencedCustomJobIds = new Set(); + + public void forget(String customJobId) { + resultIdByCustomJobId.remove(customJobId); + } + + public void markResequenced(String customJobId) { + resequencedCustomJobIds.add(customJobId); + } + + @SuppressWarnings('PMD.AvoidDebugStatements') + public void recordFrameworkFailure( + String chainId, + QueueableJob currentJob, + Exception frameworkFailure + ) { + try { + insert as system new AsyncResult__c( + ChainId__c = chainId, + CustomJobId__c = currentJob?.customJobId, + ClassName__c = currentJob?.className, + SalesforceJobId__c = currentJob?.salesforceJobId, + Status__c = QueueableManager.STATUS_FRAMEWORK_ERROR, + ExceptionType__c = frameworkFailure.getTypeName(), + ExceptionMessage__c = QueueableManager.frameworkFailureMessage(frameworkFailure) + ); + } catch (Exception recordingFailure) { + System.debug( + LoggingLevel.ERROR, + QueueableManager.frameworkFailureMessage(frameworkFailure) + + '\nThe failure could not be recorded either: ' + + recordingFailure.getMessage() + ); + } + } + + public void recordResults( + List candidates, + Map outcomeByCustomJobId + ) { + List toRecord = new List(); + for (QueueableJob job : candidates) { + if (job.resultCreated) { + continue; + } + if (resultEnabledFor(job)) { + toRecord.add(job); + } else { + job.resultCreated = true; + AsyncRequeue.releasePayload(job); + } + } + if (toRecord.isEmpty()) { + return; + } + + List toInsert = new List(); + List toUpdate = new List(); + List inserted = new List(); + List updated = new List(); + Integer linkRoom = recordLinkRoomFor(toRecord.size()); + for (QueueableJob job : toRecord) { + applyRecordLinkCap(job); + linkRoom -= applyDmlRowBudget(job, linkRoom); + AsyncResult__c result = buildResult(job); + if (result.Id == null) { + toInsert.add(result); + inserted.add(job); + } else { + toUpdate.add(result); + updated.add(job); + } + } + List all = new List(toInsert); + all.addAll(toUpdate); + dropLinksToSourcesTheCleanupBatchRemoved(all); + insert as system toInsert; + for (Integer i = 0; i < inserted.size(); i++) { + resultIdByCustomJobId.put(inserted[i].customJobId, toInsert[i].Id); + } + updateOrReplacePlaceholders(toUpdate, updated); + for (QueueableJob job : toRecord) { + job.resultCreated = true; + AsyncRequeue.releasePayload(job); + } + writeRecordLinks(toRecord); + linkDependencyResults(toRecord, outcomeByCustomJobId); + } + + private Boolean resultEnabledFor(QueueableJob job) { + return QueueableJobSettings.createResultEnabledFor(job) || + payloadStorageOnAndJobDidNotSucceed(job) || + QueueableManager.trackProgressEnabledFor(job) || + hasRecordLinks(job); + } + + private Boolean hasRecordLinks(QueueableJob job) { + return (job.recordLinksWritten ?? 0) > 0 || (job.links != null && !job.links.isEmpty()); + } + + private Boolean payloadStorageOnAndJobDidNotSucceed(QueueableJob job) { + return job.requeueStatus != null && (job.hasFailed || job.skipStatus != null); + } + + private void updateOrReplacePlaceholders( + List toUpdate, + List updated + ) { + List outcomes = Database.update( + toUpdate, + false, + AccessLevel.SYSTEM_MODE + ); + List replacements = new List(); + List replaced = new List(); + for (Integer i = 0; i < outcomes.size(); i++) { + if (outcomes[i].isSuccess()) { + continue; + } + QueueableJob job = updated[i]; + job.recordConfigurationWarning( + String.format( + QueueableManager.WARNING_PROGRESS_ROW_REPLACED, + new List{ job.className, outcomes[i].getErrors()[0].getMessage() } + ) + ); + resultIdByCustomJobId.remove(job.customJobId); + job.recordLinksWritten = 0; + replacements.add(buildResult(job)); + replaced.add(job); + } + if (replacements.isEmpty()) { + return; + } + dropLinksToSourcesTheCleanupBatchRemoved(replacements); + insert as system replacements; + for (Integer i = 0; i < replaced.size(); i++) { + resultIdByCustomJobId.put(replaced[i].customJobId, replacements[i].Id); + } + } + + public void createEnqueuedPlaceholders(List jobs, QueueableJob currentJob) { + resequencePlaceholders(jobs, currentJob); + List pending = new List(); + for (QueueableJob job : jobs) { + if ( + !job.isProcessed && + !resultIdByCustomJobId.containsKey(job.customJobId) && + QueueableManager.trackProgressEnabledFor(job) + ) { + pending.add(job); + } + } + if (pending.isEmpty()) { + return; + } + List rows = new List(); + Integer linkRoom = recordLinkRoomFor(pending.size()); + for (QueueableJob job : pending) { + applyRecordLinkCap(job); + linkRoom -= applyDmlRowBudget(job, linkRoom); + AsyncResult__c row = identityOf(job); + row.Status__c = QueueableManager.STATUS_ENQUEUED; + rows.add(row); + } + try { + insert as system rows; + } catch (Exception writeFailure) { + recordProgressRowNotWritten(pending[0], writeFailure); + return; + } + for (Integer i = 0; i < pending.size(); i++) { + resultIdByCustomJobId.put(pending[i].customJobId, rows[i].Id); + } + writeRecordLinks(pending); + } + + private void resequencePlaceholders(List jobs, QueueableJob currentJob) { + List moved = new List(); + for (QueueableJob job : jobs) { + Id placeholderId = resultIdByCustomJobId.get(job.customJobId); + if (placeholderId != null && resequencedCustomJobIds.contains(job.customJobId)) { + moved.add( + new AsyncResult__c(Id = placeholderId, ChainSequence__c = job.chainSequence) + ); + } + } + resequencedCustomJobIds.clear(); + if (moved.isEmpty()) { + return; + } + try { + update as system moved; + } catch (Exception writeFailure) { + recordProgressRowNotWritten(currentJob ?? jobs[0], writeFailure); + } + } + + public void deletePlaceholdersOfUnprocessedJobs( + List jobs, + QueueableJob failedJob + ) { + List placeholders = new List(); + for (QueueableJob job : jobs) { + Id placeholderId = resultIdByCustomJobId.get(job.customJobId); + if (!job.isProcessed && placeholderId != null) { + placeholders.add(new AsyncResult__c(Id = placeholderId)); + resultIdByCustomJobId.remove(job.customJobId); + job.recordLinksWritten = 0; + } + } + try { + delete as system placeholders; + } catch (Exception deleteFailure) { + recordProgressRowNotWritten(failedJob, deleteFailure); + } + } + + public void stampSalesforceJobId(QueueableJob enqueuedJob) { + Id placeholderId = resultIdByCustomJobId.get(enqueuedJob.customJobId); + if (placeholderId == null || enqueuedJob.salesforceJobId == null) { + return; + } + AsyncResult__c stamped = new AsyncResult__c( + Id = placeholderId, + SalesforceJobId__c = enqueuedJob.salesforceJobId + ); + Integer delayMinutes = delayMinutesOf(enqueuedJob); + if (enqueuedJob.retryAttempt == 0 && delayMinutes > 0) { + stamped.NextAttemptAt__c = System.now().addMinutes(delayMinutes); + } + try { + update as system stamped; + } catch (Exception writeFailure) { + recordProgressRowNotWritten(enqueuedJob, writeFailure); + } + } + + public void updatePlaceholderForRetry( + QueueableJob previousJob, + Integer nextAttempt, + Integer delayMinutes + ) { + Id placeholderId = resultIdByCustomJobId.get(previousJob.customJobId); + if (placeholderId == null) { + return; + } + try { + update as system new AsyncResult__c( + Id = placeholderId, + RetryAttempts__c = nextAttempt, + RetryHistory__c = previousJob.retryHistory, + NextAttemptAt__c = System.now().addMinutes(delayMinutes ?? 0) + ); + } catch (Exception writeFailure) { + recordProgressRowNotWritten(previousJob, writeFailure); + } + } + + private Integer delayMinutesOf(QueueableJob job) { + return job.delay ?? job.asyncOptions?.MinimumQueueableDelayInMinutes ?? 0; + } + + private void recordProgressRowNotWritten(QueueableJob job, Exception writeFailure) { + job.recordConfigurationWarning( + String.format( + QueueableManager.WARNING_PROGRESS_ROW_NOT_WRITTEN, + new List{ job.className, writeFailure.getMessage() } + ) + ); + } + + private void applyRecordLinkCap(QueueableJob job) { + if (job.links == null) { + return; + } + Integer written = job.recordLinksWritten ?? 0; + Integer allowed = QueueableManager.maxRecordLinksFor(job) - written; + if (job.links.size() <= allowed) { + return; + } + job.recordConfigurationWarning( + String.format( + QueueableManager.WARNING_RECORD_LINKS_TRUNCATED, + new List{ + String.valueOf(allowed + written), + job.className, + String.valueOf(written + job.links.size()) + } + ) + ); + job.links = firstOf(job.links, allowed); + } + + private Integer recordLinkRoomFor(Integer resultRows) { + return Limits.getLimitDmlRows() - Limits.getDmlRows() - dmlRowReserve - resultRows; + } + + private Integer applyDmlRowBudget(QueueableJob job, Integer room) { + if (job.links == null || job.links.isEmpty()) { + return 0; + } + Integer stored = Math.min(Math.max(room, 0), job.links.size()); + if (stored == job.links.size()) { + return stored; + } + job.recordConfigurationWarning( + String.format( + QueueableManager.WARNING_RECORD_LINKS_DML_BUDGET, + new List{ + String.valueOf(stored), + String.valueOf(job.links.size()), + job.className + } + ) + ); + job.links = firstOf(job.links, stored); + return stored; + } + + private static List firstOf(List links, Integer count) { + List kept = new List(); + for (Integer i = 0; i < Math.max(count, 0); i++) { + kept.add(links[i]); + } + return kept; + } + + private void writeRecordLinks(List recordedJobs) { + List rows = new List(); + List owners = new List(); + for (QueueableJob job : recordedJobs) { + Id resultId = resultIdByCustomJobId.get(job.customJobId); + if (resultId == null || job.links == null || job.links.isEmpty()) { + continue; + } + for (Async.RecordLink link : job.links) { + rows.add( + new AsyncResultLink__c( + AsyncResult__c = resultId, + Key__c = link.key, + Source__c = link.source, + ObjectName__c = link.objectName, + UniqueKey__c = uniqueKeyOf(resultId, link) + ) + ); + owners.add(job); + } + job.recordLinksWritten = (job.recordLinksWritten ?? 0) + job.links.size(); + job.links = null; + } + if (rows.isEmpty()) { + return; + } + List outcomes = Database.insert(rows, false, AccessLevel.SYSTEM_MODE); + for (Integer i = 0; i < outcomes.size(); i++) { + if (outcomes[i].isSuccess()) { + continue; + } + Database.Error error = outcomes[i].getErrors()[0]; + owners[i].recordLinksWritten--; + if (error.getStatusCode() == StatusCode.DUPLICATE_VALUE) { + continue; + } + owners[i] + .recordConfigurationWarning( + String.format( + QueueableManager.WARNING_RECORD_LINKS_NOT_WRITTEN, + new List{ owners[i].className, error.getMessage() } + ) + ); + } + } + + private static String uniqueKeyOf(Id resultId, Async.RecordLink link) { + Blob identity = Blob.valueOf(QueueableManager.linkIdentityOf(link)); + return resultId + ':' + EncodingUtil.convertToHex(Crypto.generateDigest('SHA1', identity)); + } + + private void dropLinksToSourcesTheCleanupBatchRemoved(List results) { + Set sourceIds = new Set(); + for (AsyncResult__c result : results) { + if (result.RequeuedFrom__c != null) { + sourceIds.add(result.RequeuedFrom__c); + } + } + if (sourceIds.isEmpty()) { + return; + } + Set stillThere = new Map( + [SELECT Id FROM AsyncResult__c WHERE Id IN :sourceIds WITH SYSTEM_MODE] + ) + .keySet(); + for (AsyncResult__c result : results) { + if (result.RequeuedFrom__c != null && !stillThere.contains(result.RequeuedFrom__c)) { + result.RequeuedFrom__c = null; + } + } + } + + private AsyncResult__c identityOf(QueueableJob job) { + AsyncResult__c row = new AsyncResult__c( + SalesforceJobId__c = job.salesforceJobId, + CustomJobId__c = job.customJobId, + ChainId__c = job.chainId, + ClassName__c = job.className, + JobName__c = job.jobName, + ChainName__c = job.chainName, + ChainSequence__c = job.chainSequence, + Priority__c = job.priority, + ParentCustomJobId__c = job.parentCustomJobId, + AddedByCustomJobId__c = job.addedByCustomJobId, + Dependencies__c = job.dependencies == null ? null : JSON.serialize(job.dependencies), + RetryAttempts__c = job.retryAttempt, + RetryHistory__c = job.retryHistory, + RequeuedFrom__c = job.requeuedFromResultId + ); + if (job.retryAttempt == 0) { + Integer delayMinutes = delayMinutesOf(job); + row.DelayMinutes__c = delayMinutes > 0 ? delayMinutes : null; + } + ChunkRun run = job instanceof ChunkJob ? ((ChunkJob) job).getRun() : null; + if (run?.chunkSize != null) { + row.ChunkRunId__c = run.runId; + row.ChunkPage__c = run.currentPageNumber(); + row.ChunkPages__c = run.totalPages(); + } + return row; + } + + private AsyncResult__c buildResult(QueueableJob job) { + AsyncResult__c result = identityOf(job); + result.Id = resultIdByCustomJobId.get(job.customJobId); + result.StartedAt__c = job.startedAt; + result.FinishedAt__c = System.now(); + result.NextAttemptAt__c = null; + if (payloadStorageOnAndJobDidNotSucceed(job)) { + result.JobPayload__c = job.requeuePayload; + result.PayloadSize__c = job.requeuePayloadSize; + result.RequeueStatus__c = job.requeueStatus; + } + if (job.skipStatus != null) { + result.Status__c = job.skipStatus; + result.SkipReason__c = job.skipReason; + } else if (job.hasFailed) { + result.Status__c = QueueableManager.STATUS_FAILED; + result.Result__c = job.finalizerCtx?.getResult()?.name(); + result.ExceptionType__c = job.failure?.type; + result.ExceptionMessage__c = job.failure?.message; + } else { + result.Status__c = QueueableManager.STATUS_COMPLETED; + result.Result__c = job.finalizerCtx?.getResult()?.name(); + } + return result; + } + + private void linkDependencyResults( + List recordedJobs, + Map outcomeByCustomJobId + ) { + List toUpdate = new List(); + for (QueueableJob job : recordedJobs) { + Async.Dependency decisive = decisiveDependency(job, outcomeByCustomJobId); + Id dependencyResultId = decisive == null + ? null + : resultIdByCustomJobId.get(decisive.resolvedTargetCustomJobId); + if (dependencyResultId == null) { + continue; + } + Async.Outcome actual = outcomeByCustomJobId.get(decisive.resolvedTargetCustomJobId); + toUpdate.add( + new AsyncResult__c( + Id = resultIdByCustomJobId.get(job.customJobId), + DependsOnResult__c = dependencyResultId, + RequiredOutcome__c = decisive.requiredOutcome.name(), + ActualOutcome__c = actual?.name() ?? 'NOT_RUN' + ) + ); + } + if (!toUpdate.isEmpty()) { + update as system toUpdate; + } + } + + private static Async.Dependency decisiveDependency( + QueueableJob job, + Map outcomeByCustomJobId + ) { + if (job.dependencies == null || job.dependencies.isEmpty()) { + return null; + } + Async.Dependency blocking = QueueableManager.firstUnsatisfiedDependency( + job, + outcomeByCustomJobId + ); + return blocking ?? job.dependencies[0]; + } +} diff --git a/force-app/main/default/classes/queue/AsyncResultWriter.cls-meta.xml b/force-app/main/default/classes/queue/AsyncResultWriter.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/force-app/main/default/classes/queue/AsyncResultWriter.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/force-app/main/default/classes/queue/ChunkBuilder.cls b/force-app/main/default/classes/queue/ChunkBuilder.cls index 6784293..9f76603 100644 --- a/force-app/main/default/classes/queue/ChunkBuilder.cls +++ b/force-app/main/default/classes/queue/ChunkBuilder.cls @@ -5,10 +5,11 @@ * v3.1.0 * * PMD False Positives: - * - CyclomaticComplexity: every fluent method validates its own argument + * - CyclomaticComplexity / CognitiveComplexity: every fluent method validates its own argument, + * and the most complex member scores 8 * - ExcessivePublicCount: one fluent method per chunk option **/ -@SuppressWarnings('PMD.CyclomaticComplexity,PMD.ExcessivePublicCount') +@SuppressWarnings('PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.ExcessivePublicCount') public inherited sharing class ChunkBuilder { @TestVisible private static final String ERROR_MESSAGE_INVALID_CHUNK_SIZE = 'chunkSize must be a positive integer'; @@ -91,10 +92,71 @@ public inherited sharing class ChunkBuilder { } public ChunkBuilder priority(Integer priority) { + if (job.nextInChain) { + throw new IllegalArgumentException( + QueueableManager.ERROR_MESSAGE_PRIORITY_WITH_NEXT_IN_CHAIN + ); + } job.priority = priority; return this; } + public ChunkBuilder name(String jobName) { + job.jobName = jobName; + return this; + } + + public ChunkBuilder chainName(String chainName) { + job.chainName = chainName; + return this; + } + + public ChunkBuilder linkRecord(SObject record) { + return linkRecord(record?.Id); + } + + public ChunkBuilder linkRecord(Id recordId) { + job.linkRecords(new List{ recordId }); + return this; + } + + public ChunkBuilder linkRecords(List records) { + job.linkRecords(QueueableManager.idsOf(records)); + return this; + } + + public ChunkBuilder linkRecords(List recordIds) { + job.linkRecords(recordIds); + return this; + } + + public ChunkBuilder linkRecords(Set recordIds) { + job.linkRecords(recordIds == null ? null : new List(recordIds)); + return this; + } + + public ChunkBuilder linkExternalRecord(String source, String objectName, String key) { + job.linkExternalRecords( + new List{ new Async.RecordLink(source, objectName, key) } + ); + return this; + } + + public ChunkBuilder linkExternalRecords(List links) { + job.linkExternalRecords(links); + return this; + } + + public ChunkBuilder nextInChain() { + if (job.priority != null) { + throw new IllegalArgumentException( + QueueableManager.ERROR_MESSAGE_PRIORITY_WITH_NEXT_IN_CHAIN + ); + } + job.nextInChain = true; + return this; + } + public ChunkBuilder delay(Integer delay) { job.delay = delay; return this; diff --git a/force-app/main/default/classes/queue/ChunkJob.cls b/force-app/main/default/classes/queue/ChunkJob.cls index 021caff..2a6f87e 100644 --- a/force-app/main/default/classes/queue/ChunkJob.cls +++ b/force-app/main/default/classes/queue/ChunkJob.cls @@ -45,6 +45,9 @@ public inherited sharing abstract class ChunkJob extends QueueableJob { nextPage.finalizerCtx = null; nextPage.isProcessed = false; nextPage.resultCreated = false; + nextPage.nextInChain = false; + nextPage.links = null; + nextPage.recordLinksWritten = 0; if (!restoreStateOnNextChunk && nextPage instanceof Async.ChunkResettable) { ((Async.ChunkResettable) nextPage) .resetBeforeNextChunk(nextPage.run.currentPageNumber()); diff --git a/force-app/main/default/classes/queue/ChunkRun.cls b/force-app/main/default/classes/queue/ChunkRun.cls index c6d1088..0460f51 100644 --- a/force-app/main/default/classes/queue/ChunkRun.cls +++ b/force-app/main/default/classes/queue/ChunkRun.cls @@ -79,7 +79,7 @@ public inherited sharing class ChunkRun { ); } - private Integer totalPages() { + public Integer totalPages() { return (totalSize + chunkSize - 1) / chunkSize; } diff --git a/force-app/main/default/classes/queue/QueueableBuilder.cls b/force-app/main/default/classes/queue/QueueableBuilder.cls index 721f82c..a7d52ec 100644 --- a/force-app/main/default/classes/queue/QueueableBuilder.cls +++ b/force-app/main/default/classes/queue/QueueableBuilder.cls @@ -6,9 +6,13 @@ * * PMD False Positives: * - ExcessivePublicCount: one fluent method per job option + * - CyclomaticComplexity / CognitiveComplexity: one guard clause per fluent method, and the most + * complex member scores 8 * - EmptyStatementBlock: Apex requires an explicit no-arg constructor once another exists **/ -@SuppressWarnings('PMD.ExcessivePublicCount,PMD.EmptyStatementBlock') +@SuppressWarnings( + 'PMD.ExcessivePublicCount,PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.EmptyStatementBlock' +) public inherited sharing class QueueableBuilder { private QueueableJob job; private String lastChainedCustomJobId; @@ -65,10 +69,71 @@ public inherited sharing class QueueableBuilder { } public QueueableBuilder priority(Integer priority) { + if (job.nextInChain) { + throw new IllegalArgumentException( + QueueableManager.ERROR_MESSAGE_PRIORITY_WITH_NEXT_IN_CHAIN + ); + } job.priority = priority; return this; } + public QueueableBuilder name(String jobName) { + job.jobName = jobName; + return this; + } + + public QueueableBuilder chainName(String chainName) { + job.chainName = chainName; + return this; + } + + public QueueableBuilder linkRecord(SObject record) { + return linkRecord(record?.Id); + } + + public QueueableBuilder linkRecord(Id recordId) { + job.linkRecords(new List{ recordId }); + return this; + } + + public QueueableBuilder linkRecords(List records) { + job.linkRecords(QueueableManager.idsOf(records)); + return this; + } + + public QueueableBuilder linkRecords(List recordIds) { + job.linkRecords(recordIds); + return this; + } + + public QueueableBuilder linkRecords(Set recordIds) { + job.linkRecords(recordIds == null ? null : new List(recordIds)); + return this; + } + + public QueueableBuilder linkExternalRecord(String source, String objectName, String key) { + job.linkExternalRecords( + new List{ new Async.RecordLink(source, objectName, key) } + ); + return this; + } + + public QueueableBuilder linkExternalRecords(List links) { + job.linkExternalRecords(links); + return this; + } + + public QueueableBuilder nextInChain() { + if (job.priority != null) { + throw new IllegalArgumentException( + QueueableManager.ERROR_MESSAGE_PRIORITY_WITH_NEXT_IN_CHAIN + ); + } + job.nextInChain = true; + return this; + } + public QueueableBuilder continueOnJobEnqueueFail() { job.continueOnJobEnqueueFail = true; return this; diff --git a/force-app/main/default/classes/queue/QueueableChain.cls b/force-app/main/default/classes/queue/QueueableChain.cls index 989c2dd..3ec83fc 100644 --- a/force-app/main/default/classes/queue/QueueableChain.cls +++ b/force-app/main/default/classes/queue/QueueableChain.cls @@ -5,11 +5,12 @@ * v3.1.0 * * PMD False Positives: - * - CognitiveComplexity / CyclomaticComplexity / StdCyclomaticComplexity / NcssTypeCount: This - * was intended to have all the logic in one class + * - CognitiveComplexity / CyclomaticComplexity / StdCyclomaticComplexity / NcssTypeCount / + * TooManyFields: every ordering rule of a chain lives here on purpose; result rows are + * written by AsyncResultWriter and settings read by QueueableJobSettings **/ @SuppressWarnings( - 'PMD.CognitiveComplexity,PMD.CyclomaticComplexity,PMD.StdCyclomaticComplexity,PMD.NcssTypeCount' + 'PMD.CognitiveComplexity,PMD.CyclomaticComplexity,PMD.StdCyclomaticComplexity,PMD.NcssTypeCount,PMD.TooManyFields' ) public inherited sharing class QueueableChain { @TestVisible @@ -25,27 +26,10 @@ public inherited sharing class QueueableChain { private Boolean wasStoppedBeforeAttempt; private QueueableJob currentJob; private Map outcomeByCustomJobId = new Map(); - private Map resultIdByCustomJobId = new Map(); + private AsyncResultWriter resultWriter = new AsyncResultWriter(); private String chainId; - - @TestVisible - public static Map jobSettingByName { - get { - if (jobSettingByName == null) { - jobSettingByName = fromCustomMetadata(); - } - return jobSettingByName; - } - private set; - } - - private static Map fromCustomMetadata() { - Map settings = new Map(); - for (QueueableJobSetting__mdt setting : QueueableJobSetting__mdt.getAll().values()) { - settings.put(setting.QueueableJobName__c, setting); - } - return settings; - } + private String chainName; + private Integer nextInChainSlot; public void execute(QueueableContext ctx) { QueueableManager.get().setChain(this); @@ -68,6 +52,8 @@ public inherited sharing class QueueableChain { currentJob.queueableCtx = ctx; currentJob.salesforceJobId = ctx.getJobId(); currentJob.setChainCounterToName(++chainCounter); + currentJob.startedAt = System.now(); + nextInChainSlot = currentJob.chainSequence; customJobIdsBeforeAttempt = currentCustomJobIds(); skippedCustomJobIdsBeforeAttempt = currentSkippedCustomJobIds(); wasStoppedBeforeAttempt = isStopped; @@ -121,28 +107,8 @@ public inherited sharing class QueueableChain { enqueueNextJobIfAny(); } - // Ignores CreateResult__c on purpose: opting out of routine bookkeeping is not opting out of - // being told the framework broke. The debug is the last channel left if the insert fails too. - @SuppressWarnings('PMD.AvoidDebugStatements') public void recordFrameworkFailure(Exception frameworkFailure) { - try { - insert as system new AsyncResult__c( - ChainId__c = chainId, - CustomJobId__c = currentJob?.customJobId, - ClassName__c = currentJob?.className, - SalesforceJobId__c = currentJob?.salesforceJobId, - Status__c = QueueableManager.STATUS_FRAMEWORK_ERROR, - ExceptionType__c = frameworkFailure.getTypeName(), - ExceptionMessage__c = QueueableManager.frameworkFailureMessage(frameworkFailure) - ); - } catch (Exception recordingFailure) { - System.debug( - LoggingLevel.ERROR, - QueueableManager.frameworkFailureMessage(frameworkFailure) + - '\nThe failure could not be recorded either: ' + - recordingFailure.getMessage() - ); - } + resultWriter.recordFrameworkFailure(chainId, currentJob, frameworkFailure); } private void maybeAdvanceChunkRun(QueueableJob previousJob) { @@ -188,7 +154,7 @@ public inherited sharing class QueueableChain { } if (!ownsRunLevelOutcome((ChunkJob) job)) { outcomeByCustomJobId.remove(job.customJobId); - resultIdByCustomJobId.remove(job.customJobId); + resultWriter.forget(job.customJobId); } jobs.remove(i); } @@ -264,6 +230,7 @@ public inherited sharing class QueueableChain { QueueableJob retryJob = QueueableManager.retryJobFrom(previousJob); previousJob.recordRetryHistory(nextAttempt, delayMinutes); + resultWriter.updatePlaceholderForRetry(previousJob, nextAttempt, delayMinutes); previousJob.isProcessed = true; // Only the final attempt produces a result row; suppress this superseded one. previousJob.resultCreated = true; @@ -282,7 +249,7 @@ public inherited sharing class QueueableChain { retryJob.requeueStatus = previousJob.requeueStatus; retryJob.requeuePayloadSize = previousJob.requeuePayloadSize; retryJob.requeuedFromResultId = previousJob.requeuedFromResultId; - releasePayload(previousJob); + AsyncRequeue.releasePayload(previousJob); QueueableManager.runRetryReset(retryJob, nextAttempt); revertChainChangesBeforeRetry(); @@ -352,7 +319,7 @@ public inherited sharing class QueueableChain { private void discardJobAt(Integer index) { QueueableJob job = jobs[index]; outcomeByCustomJobId.remove(job.customJobId); - resultIdByCustomJobId.remove(job.customJobId); + resultWriter.forget(job.customJobId); jobs.remove(index); } @@ -374,20 +341,6 @@ public inherited sharing class QueueableChain { return customJobIds; } - private Boolean resultEnabledFor(QueueableJob job) { - return createResultEnabledFor(job) || payloadStorageOnAndJobDidNotSucceed(job); - } - - private Boolean createResultEnabledFor(QueueableJob job) { - return jobSettingByName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL)?.CreateResult__c == - true || - jobSettingByName.get(job.className)?.CreateResult__c == true; - } - - private Boolean payloadStorageOnAndJobDidNotSucceed(QueueableJob job) { - return job.requeueStatus != null && (job.hasFailed || job.skipStatus != null); - } - private void createPendingResults() { List candidates = new List(); for (QueueableJob job : jobs) { @@ -395,128 +348,7 @@ public inherited sharing class QueueableChain { candidates.add(job); } } - recordResults(candidates); - } - - private void recordResults(List candidates) { - List toRecord = new List(); - for (QueueableJob job : candidates) { - if (job.resultCreated) { - continue; - } - if (resultEnabledFor(job)) { - toRecord.add(job); - } else { - job.resultCreated = true; - releasePayload(job); - } - } - if (toRecord.isEmpty()) { - return; - } - - List results = new List(); - for (QueueableJob job : toRecord) { - results.add(buildResult(job)); - } - dropLinksToSourcesTheCleanupBatchRemoved(results); - insert as system results; - for (Integer i = 0; i < toRecord.size(); i++) { - toRecord[i].resultCreated = true; - resultIdByCustomJobId.put(toRecord[i].customJobId, results[i].Id); - releasePayload(toRecord[i]); - } - linkDependencyResults(toRecord); - } - - // The payload rides the serialized job into every later execution context, so a job that kept - // it would carry its own size twice for the rest of the chain. - private void releasePayload(QueueableJob job) { - job.requeuePayload = null; - } - - private void dropLinksToSourcesTheCleanupBatchRemoved(List results) { - Set sourceIds = new Set(); - for (AsyncResult__c result : results) { - if (result.RequeuedFrom__c != null) { - sourceIds.add(result.RequeuedFrom__c); - } - } - if (sourceIds.isEmpty()) { - return; - } - Set stillThere = new Map( - [SELECT Id FROM AsyncResult__c WHERE Id IN :sourceIds WITH SYSTEM_MODE] - ) - .keySet(); - for (AsyncResult__c result : results) { - if (result.RequeuedFrom__c != null && !stillThere.contains(result.RequeuedFrom__c)) { - result.RequeuedFrom__c = null; - } - } - } - - private AsyncResult__c buildResult(QueueableJob job) { - AsyncResult__c result = new AsyncResult__c( - SalesforceJobId__c = job.salesforceJobId, - CustomJobId__c = job.customJobId, - ChainId__c = job.chainId, - ClassName__c = job.className, - RetryAttempts__c = job.retryAttempt, - RetryHistory__c = job.retryHistory, - RequeuedFrom__c = job.requeuedFromResultId - ); - if (payloadStorageOnAndJobDidNotSucceed(job)) { - result.JobPayload__c = job.requeuePayload; - result.PayloadSize__c = job.requeuePayloadSize; - result.RequeueStatus__c = job.requeueStatus; - } - if (job.skipStatus != null) { - result.Status__c = job.skipStatus; - result.SkipReason__c = job.skipReason; - } else if (job.hasFailed) { - result.Status__c = QueueableManager.STATUS_FAILED; - result.Result__c = job.finalizerCtx?.getResult()?.name(); - result.ExceptionType__c = job.failure?.type; - result.ExceptionMessage__c = job.failure?.message; - } else { - result.Status__c = QueueableManager.STATUS_COMPLETED; - result.Result__c = job.finalizerCtx?.getResult()?.name(); - } - return result; - } - - private void linkDependencyResults(List recordedJobs) { - List toUpdate = new List(); - for (QueueableJob job : recordedJobs) { - Async.Dependency decisive = decisiveDependency(job); - Id dependencyResultId = decisive == null - ? null - : resultIdByCustomJobId.get(decisive.resolvedTargetCustomJobId); - if (dependencyResultId == null) { - continue; - } - Async.Outcome actual = outcomeByCustomJobId.get(decisive.resolvedTargetCustomJobId); - toUpdate.add( - new AsyncResult__c( - Id = resultIdByCustomJobId.get(job.customJobId), - DependsOnResult__c = dependencyResultId, - RequiredOutcome__c = decisive.requiredOutcome.name(), - ActualOutcome__c = actual?.name() ?? 'NOT_RUN' - ) - ); - } - if (!toUpdate.isEmpty()) { - update as system toUpdate; - } - } - - private Async.Dependency decisiveDependency(QueueableJob job) { - if (job.dependencies == null || job.dependencies.isEmpty()) { - return null; - } - Async.Dependency blocking = firstUnsatisfiedDependency(job); - return blocking ?? job.dependencies[0]; + resultWriter.recordResults(candidates, outcomeByCustomJobId); } public void enqueueNextJobIfAny() { @@ -528,17 +360,21 @@ public inherited sharing class QueueableChain { } QueueableJob nextJob = getNextJobToProcess(); + resultWriter.createEnqueuedPlaceholders(jobs, currentJob); try { isChainedContext = true; nextJob.enqueue(this); } catch (Exception ex) { if (!nextJob.continueOnJobEnqueueFail) { + resultWriter.deletePlaceholdersOfUnprocessedJobs(jobs, nextJob); throw ex; } isChainedContext = false; nextJob.isProcessed = true; enqueueNextJobIfAny(); + return; } + resultWriter.stampSalesforceJobId(nextJob); } public void stop() { @@ -603,7 +439,10 @@ public inherited sharing class QueueableChain { private void skipJobsWithUnsatisfiedDependencies() { QueueableJob candidate = getNextJobToProcess(); - while (candidate != null && firstUnsatisfiedDependency(candidate) != null) { + while ( + candidate != null && + QueueableManager.firstUnsatisfiedDependency(candidate, outcomeByCustomJobId) != null + ) { markSkipped( candidate, QueueableManager.STATUS_SKIPPED_DEPENDENCY, @@ -613,21 +452,11 @@ public inherited sharing class QueueableChain { } } - private Async.Dependency firstUnsatisfiedDependency(QueueableJob job) { - if (job.dependencies == null) { - return null; - } - for (Async.Dependency dependency : job.dependencies) { - Async.Outcome actual = outcomeByCustomJobId.get(dependency.resolvedTargetCustomJobId); - if (!outcomeMatches(actual, dependency.requiredOutcome)) { - return dependency; - } - } - return null; - } - private String buildDependencySkipReason(QueueableJob job) { - Async.Dependency blocking = firstUnsatisfiedDependency(job); + Async.Dependency blocking = QueueableManager.firstUnsatisfiedDependency( + job, + outcomeByCustomJobId + ); Async.Outcome actual = outcomeByCustomJobId.get(blocking.resolvedTargetCustomJobId); return 'Dependency \'' + labelForCustomJobId(blocking.resolvedTargetCustomJobId) + @@ -646,18 +475,16 @@ public inherited sharing class QueueableChain { return customJobId; } - private Boolean outcomeMatches(Async.Outcome actual, Async.Outcome required) { - if (actual == null) { - return false; - } - return required == Async.Outcome.COMPLETED || actual == required; - } - public Boolean hasNextJob() { return getNextJobToProcess() != null; } public void addJob(QueueableJob job) { + chainName = job.chainName ?? chainName; + for (QueueableJob queued : jobs) { + queued.chainName = chainName; + } + job.chainName = chainName; AsyncRequeue.captureJobAsHandedOver(job); job.setMainAttributes(); if (chainId == null) { @@ -665,14 +492,46 @@ public inherited sharing class QueueableChain { } job.chainId = chainId; keepSlotOfChunkRunOrTakeNextOne(job); - applyRetryDefaultsIfNeeded(job); + placeNextInChainIfRequested(job); + QueueableJobSettings.applyRetryDefaults(job); QueueableManager.assertStateHandlingDeclared(job); job.captureEnqueuedState(); jobs.add(job); jobs.sort(); + keepSequenceInRunOrder(); AsyncEventDispatcher.jobEnqueued(job, QueueableManager.loggerClassFor(job)); } + private void keepSequenceInRunOrder() { + List waitingInRunOrder = new List(); + List slots = new List(); + for (QueueableJob queued : jobs) { + if (ownsAMovableSlot(queued)) { + waitingInRunOrder.add(queued); + slots.add(queued.chainSequence); + } + } + slots.sort(); + for (Integer i = 0; i < slots.size(); i++) { + QueueableJob waiting = waitingInRunOrder[i]; + if (waiting.chainSequence != slots[i]) { + waiting.chainSequence = slots[i]; + resultWriter.markResequenced(waiting.customJobId); + } + } + } + + private Boolean ownsAMovableSlot(QueueableJob job) { + return !job.isProcessed && + !job.isFinalizer && + job.customJobId != currentJob?.customJobId && + !isLaterPageOfChunkRun(job); + } + + private Boolean isLaterPageOfChunkRun(QueueableJob job) { + return job instanceof ChunkJob && ((ChunkJob) job).getRun().currentPageNumber() > 1; + } + private void keepSlotOfChunkRunOrTakeNextOne(QueueableJob job) { job.chainSequence = job.chainSequence ?? ++jobSequence; if (job instanceof ChunkJob) { @@ -681,60 +540,27 @@ public inherited sharing class QueueableChain { } } - @TestVisible - private void applyRetryDefaultsIfNeeded(QueueableJob job) { - if (job.maxRetries > 0) { - // Explicit fluent .retry(...) wins over CMDT defaults. + private void placeNextInChainIfRequested(QueueableJob job) { + if (job.nextInChain != true) { return; } - - QueueableJobSetting__mdt setting = resolveRetrySetting(job.className); - if (setting == null) { - return; - } - - if (!QueueableManager.declaresRetryStateHandling(job)) { - job.recordConfigurationWarning( - String.format( - QueueableManager.WARNING_CMDT_RETRY_WITHOUT_RESET, - new List{ job.className, QueueableManager.API_PREFIX } - ) + if (currentJob == null) { + throw new IllegalArgumentException( + QueueableManager.ERROR_MESSAGE_NEXT_IN_CHAIN_OUTSIDE_CHAIN ); - return; - } - - job.maxRetries = QueueableManager.cappedRetries(job, setting.MaxRetries__c.intValue()); - QueueableManager.applyBackoffDefault(job, setting); - - if (job.retryOnExceptionTypes == null) { - job.retryOnExceptionTypes = parseExceptionTypes(setting.RetryableExceptions__c); - } - } - - private QueueableJobSetting__mdt resolveRetrySetting(String jobClassName) { - Map jobSettings = jobSettingByName; - QueueableJobSetting__mdt setting = jobSettings.get(jobClassName); - if (setting == null || setting.MaxRetries__c == null) { - setting = jobSettings.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL); - } - Boolean hasRetryDefault = - setting != null && - setting.MaxRetries__c != null && - setting.MaxRetries__c > 0; - return hasRetryDefault ? setting : null; - } - - private Set parseExceptionTypes(String csv) { - if (String.isBlank(csv)) { - return null; } - Set types = new Set(); - for (String exceptionType : csv.split(',')) { - if (String.isNotBlank(exceptionType)) { - types.add(exceptionType.trim()); + nextInChainSlot++; + for (QueueableJob queued : jobs) { + if ( + !queued.isProcessed && + !queued.isFinalizer && + queued.chainSequence >= nextInChainSlot + ) { + queued.chainSequence++; + resultWriter.markResequenced(queued.customJobId); } } - return types.isEmpty() ? null : types; + job.chainSequence = nextInChainSlot; } public List getJobs() { @@ -776,17 +602,10 @@ public inherited sharing class QueueableChain { @TestVisible private void removeJobsThatAreDisabledAndDependentFinalizers() { - Map jobSettings = jobSettingByName; - - if (jobSettings.isEmpty()) { + if (QueueableJobSettings.jobSettingByName.isEmpty()) { return; } - - QueueableJobSetting__mdt allJobSetting = jobSettings.get( - QueueableManager.QUEUEABLE_JOB_SETTING_ALL - ); - - if (allJobSetting?.IsDisabled__c == true) { + if (QueueableJobSettings.forAllJobs()?.IsDisabled__c == true) { markDisabledAndRecord(new List(jobs)); jobs.clear(); return; @@ -794,8 +613,7 @@ public inherited sharing class QueueableChain { Set customJobIdsToRemove = new Set(); for (QueueableJob job : jobs) { - QueueableJobSetting__mdt jobSetting = jobSettings.get(job.className); - if (jobSetting != null && jobSetting.IsDisabled__c) { + if (QueueableJobSettings.isDisabled(job)) { customJobIdsToRemove.add(job.customJobId); } } @@ -830,7 +648,7 @@ public inherited sharing class QueueableChain { ); } } - recordResults(disabledJobs); + resultWriter.recordResults(disabledJobs, outcomeByCustomJobId); } private Set getAllCustomJobIdsToRemove(Set customJobIdsToRemove) { diff --git a/force-app/main/default/classes/queue/QueueableJob.cls b/force-app/main/default/classes/queue/QueueableJob.cls index 5464862..bad7e08 100644 --- a/force-app/main/default/classes/queue/QueueableJob.cls +++ b/force-app/main/default/classes/queue/QueueableJob.cls @@ -56,6 +56,14 @@ public without sharing abstract class QueueableJob implements Queueable, Compara public FinalizerContext finalizerCtx; public String mockId; + public String jobName; + public String chainName; + public List links; + public Boolean nextInChain = false; + public String addedByCustomJobId; + public Datetime startedAt; + public Integer recordLinksWritten = 0; + @TestVisible private QueueableChain chain; @TestVisible @@ -86,7 +94,7 @@ public without sharing abstract class QueueableJob implements Queueable, Compara /** * 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, before any of our code runs. See docs/api-evolution.md. + * subscriber org that referenced it, before any of our code runs. See dev/docs/api-evolution.md. **/ public virtual void resetForRetry() { } @@ -132,6 +140,9 @@ public without sharing abstract class QueueableJob implements Queueable, Compara if (!isFinalizer && compareToJob.isFinalizer) { return 1; } + if (isNextInChain() != compareToJob.isNextInChain()) { + return isNextInChain() ? -1 : 1; + } if (priority != null && compareToJob.priority == null) { return -1; } @@ -144,6 +155,10 @@ public without sharing abstract class QueueableJob implements Queueable, Compara return priority - compareToJob.priority; } + private Boolean isNextInChain() { + return nextInChain == true; + } + // 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. private Integer compareChainSequence(QueueableJob compareToJob) { @@ -161,6 +176,52 @@ public without sharing abstract class QueueableJob implements Queueable, Compara } } + public void linkRecords(List recordIds) { + List salesforceLinks = new List(); + for (Id recordId : recordIds ?? new List()) { + if (recordId != null) { + salesforceLinks.add(salesforceLinkOf(recordId)); + } + } + addLinks(salesforceLinks); + } + + public void linkExternalRecords(List externalLinks) { + for (Async.RecordLink link : externalLinks ?? new List()) { + if (link != null && String.isBlank(link.key)) { + throw new IllegalArgumentException( + QueueableManager.ERROR_MESSAGE_LINK_KEY_REQUIRED + ); + } + } + addLinks(externalLinks); + } + + public void addLinks(List toAdd) { + if (toAdd == null || toAdd.isEmpty()) { + return; + } + links = links ?? new List(); + Set known = new Set(); + for (Async.RecordLink link : links) { + known.add(QueueableManager.linkIdentityOf(link)); + } + for (Async.RecordLink link : toAdd) { + if (link != null && known.add(QueueableManager.linkIdentityOf(link))) { + links.add(link); + } + } + } + + private static Async.RecordLink salesforceLinkOf(Id recordId) { + Schema.SObjectType type = QueueableManager.sobjectTypeOrNull(recordId); + return new Async.RecordLink( + QueueableManager.SOURCE_SALESFORCE, + type == null ? null : String.valueOf(type), + recordId + ); + } + public void setMainAttributes() { this.customJobId = UUID.randomUUID().toString(); this.uniqueName = className + '::' + System.now().formatGMT('yyyy-MM-dd\'T\'HH:mm:ss.SSSZ'); @@ -295,6 +356,8 @@ public without sharing abstract class QueueableJob implements Queueable, Compara restored.restoreStateOnRetry = restoreStateOnRetry; restored.restoreStateOnNextChunk = restoreStateOnNextChunk; restored.retryHistory = retryHistory; + restored.links = links; + restored.recordLinksWritten = recordLinksWritten; restored.carryFrameworkStateFrom(this); return restored; } @@ -307,6 +370,8 @@ public without sharing abstract class QueueableJob implements Queueable, Compara * - `backoff` / `dependencies` / `failure` are Async Lib types, and JSON.serialize refuses any * graph crossing a namespace, so a subscriber's override hits "Type cannot be serialized" * - `requeuePayload` would otherwise nest the previous payload inside every new one + * - `links` are read back from the stored link rows on requeue, which also covers the ones + * added inside work() * Stripping a shallow copy rather than this job also covers subclass overrides. **/ public QueueableJob copyWithoutRuntimeState() { @@ -319,6 +384,11 @@ public without sharing abstract class QueueableJob implements Queueable, Compara stripped.failure = null; stripped.enqueuedState = null; stripped.requeuePayload = null; + stripped.links = null; + stripped.startedAt = null; + stripped.addedByCustomJobId = null; + stripped.nextInChain = false; + stripped.recordLinksWritten = 0; stripped.clearFrameworkStateForCopy(); return stripped; } @@ -330,6 +400,7 @@ public without sharing abstract class QueueableJob implements Queueable, Compara copy.backoff = this.backoff; copy.dependencies = deepCopyOf(this.dependencies); copy.failure = deepCopyOf(this.failure); + copy.addedByCustomJobId = this.addedByCustomJobId; return copy; } @@ -376,6 +447,9 @@ public without sharing abstract class QueueableJob implements Queueable, Compara @SuppressWarnings('PMD.AvoidDebugStatements') public void recordConfigurationWarning(String warning) { + if (retryHistory?.contains(warning) == true) { + return; + } appendRetryHistoryLine(warning); System.debug(LoggingLevel.ERROR, warning); } diff --git a/force-app/main/default/classes/queue/QueueableJobSettings.cls b/force-app/main/default/classes/queue/QueueableJobSettings.cls new file mode 100644 index 0000000..522b4ac --- /dev/null +++ b/force-app/main/default/classes/queue/QueueableJobSettings.cls @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public inherited sharing class QueueableJobSettings { + @TestVisible + public static Map jobSettingByName { + get { + if (jobSettingByName == null) { + jobSettingByName = fromCustomMetadata(); + } + return jobSettingByName; + } + private set; + } + + private static Map fromCustomMetadata() { + Map settings = new Map(); + for (QueueableJobSetting__mdt setting : QueueableJobSetting__mdt.getAll().values()) { + settings.put(setting.QueueableJobName__c, setting); + } + return settings; + } + + public static QueueableJobSetting__mdt forAllJobs() { + return jobSettingByName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL); + } + + public static Boolean createResultEnabledFor(QueueableJob job) { + return forAllJobs()?.CreateResult__c == true || + jobSettingByName.get(job.className)?.CreateResult__c == true; + } + + public static Boolean isDisabled(QueueableJob job) { + return jobSettingByName.get(job.className)?.IsDisabled__c == true; + } + + public static void applyRetryDefaults(QueueableJob job) { + if (job.maxRetries > 0) { + return; + } + + QueueableJobSetting__mdt setting = retrySettingFor(job.className); + if (setting == null) { + return; + } + + if (!QueueableManager.declaresRetryStateHandling(job)) { + job.recordConfigurationWarning( + String.format( + QueueableManager.WARNING_CMDT_RETRY_WITHOUT_RESET, + new List{ job.className, QueueableManager.API_PREFIX } + ) + ); + return; + } + + job.maxRetries = QueueableManager.cappedRetries(job, setting.MaxRetries__c.intValue()); + QueueableManager.applyBackoffDefault(job, setting); + + if (job.retryOnExceptionTypes == null) { + job.retryOnExceptionTypes = parseExceptionTypes(setting.RetryableExceptions__c); + } + } + + private static QueueableJobSetting__mdt retrySettingFor(String jobClassName) { + QueueableJobSetting__mdt setting = jobSettingByName.get(jobClassName); + if (setting == null || setting.MaxRetries__c == null) { + setting = forAllJobs(); + } + Boolean hasRetryDefault = + setting != null && + setting.MaxRetries__c != null && + setting.MaxRetries__c > 0; + return hasRetryDefault ? setting : null; + } + + private static Set parseExceptionTypes(String csv) { + if (String.isBlank(csv)) { + return null; + } + Set types = new Set(); + for (String exceptionType : csv.split(',')) { + if (String.isNotBlank(exceptionType)) { + types.add(exceptionType.trim()); + } + } + return types.isEmpty() ? null : types; + } +} diff --git a/force-app/main/default/classes/queue/QueueableJobSettings.cls-meta.xml b/force-app/main/default/classes/queue/QueueableJobSettings.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/force-app/main/default/classes/queue/QueueableJobSettings.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/force-app/main/default/classes/queue/QueueableManager.cls b/force-app/main/default/classes/queue/QueueableManager.cls index 811a2a3..ed599cb 100644 --- a/force-app/main/default/classes/queue/QueueableManager.cls +++ b/force-app/main/default/classes/queue/QueueableManager.cls @@ -21,9 +21,13 @@ public inherited sharing class QueueableManager { public static final String STATUS_SKIPPED_EXPLICIT = 'SKIPPED_EXPLICIT'; public static final String STATUS_SKIPPED_DISABLED = 'SKIPPED_DISABLED'; public static final String STATUS_FRAMEWORK_ERROR = 'FRAMEWORK_ERROR'; + public static final String STATUS_ENQUEUED = 'ENQUEUED'; public static final String DOCS_URL = 'https://async.beyondthecloud.dev'; public static final String ISSUES_URL = 'https://github.com/beyond-the-cloud-dev/async-lib/issues'; public static final Integer MAX_RETRY_CAP = 10; + public static final String SOURCE_SALESFORCE = 'Salesforce'; + public static final Integer MAX_RECORD_LINKS_CAP = 5000; + public static final Integer DEFAULT_MAX_RECORD_LINKS = 5000; public static final String ERROR_MESSAGE_INVALID_MAX_RETRIES = 'Max retries must be a non-negative integer'; public static final String ERROR_MESSAGE_MAX_RETRIES_EXCEEDS_CAP = 'Max retries cannot exceed the framework cap of ' + MAX_RETRY_CAP; @@ -48,7 +52,7 @@ public inherited sharing class QueueableManager { DOCS_URL_JOB_STATE; public static final String WARNING_UNKNOWN_BACKOFF_STRATEGY = 'QueueableJobSetting__mdt.BackoffStrategy__c is "{0}" for "{1}", which is not a known strategy, so no backoff was applied and retries run without delay. Use one of: {2}.'; public static final String WARNING_MAX_RETRIES_EXCEEDS_CAP = 'QueueableJobSetting__mdt.MaxRetries__c is {0} for "{1}", above the framework cap of {2}, so it was clamped to {2}.'; - public static final String DOCS_URL_LOGGING = DOCS_URL + '/explanations/logging'; + public static final String DOCS_URL_LOGGING = DOCS_URL + '/features/logging'; public static final String WARNING_LOGGER_NOT_FOUND = 'QueueableJobSetting__mdt.LoggerClass__c names "{0}", which could not be resolved, so no events were logged. Async Lib can only reach a class declared global. Change it to "global class {0}" and make sure the name is spelled exactly as the class. See ' + DOCS_URL_LOGGING; @@ -56,7 +60,7 @@ public inherited sharing class QueueableManager { 'QueueableJobSetting__mdt.LoggerClass__c names "{0}", which could not be constructed, so no events were logged: {1}. The class needs a public no-argument constructor. See ' + DOCS_URL_LOGGING; public static final String WARNING_LOGGER_THREW = 'An Async Lib event listener threw {0}: {1}. The job was not affected. {2}'; - public static final String DOCS_URL_REQUEUE = DOCS_URL + '/explanations/requeue'; + public static final String DOCS_URL_REQUEUE = DOCS_URL + '/features/requeue'; public static final String WARNING_PAYLOAD_NOT_CAPTURED = 'QueueableJobSetting__mdt.StoreJobPayload__c is Yes for "{0}", but the job could not be serialized, so it cannot be requeued: {1}. On a packaged install this is usually a missing JobSerializerClass__c, because JSON cannot cross a namespace boundary. Otherwise the job holds a field JSON cannot write, such as an interface or an abstract type. See ' + DOCS_URL_REQUEUE; @@ -74,7 +78,7 @@ public inherited sharing class QueueableManager { public static final String REQUEUE_SKIPPED_CLASS_NOT_FOUND = 'The class "{0}" could not be resolved, so the job cannot be rebuilt. It may have been renamed or deleted. On a packaged install, register a {1}Async.JobSerializer in QueueableJobSetting__mdt.JobSerializerClass__c. See ' + DOCS_URL_REQUEUE; - public static final String DOCS_URL_BATCH_FAILURES = DOCS_URL + '/explanations/batch-failures'; + public static final String DOCS_URL_BATCH_FAILURES = DOCS_URL + '/features/batch-failures'; public static final String REQUEUE_SKIPPED_BATCH_FAILURE = 'This is a batch failure, which has no stored payload. Requeue it with {0}Async.requeueBatchFailures(batch, batchJobId, fieldsToReload). See ' + DOCS_URL_BATCH_FAILURES; @@ -101,6 +105,25 @@ public inherited sharing class QueueableManager { public static final String ERROR_MESSAGE_INVALID_DEPENDENCY = 'A dependency requires a target and an outcome (e.g. Async.after(result).succeeded())'; public static final String ERROR_MESSAGE_DEPENDS_ON_PREVIOUS_WITHOUT_JOB = 'dependsOn(Async.afterPrevious()) requires a previously chained job'; public static final String ERROR_MESSAGE_UNKNOWN_JOB = 'No job in this chain has custom job id "{0}".'; + public static final String DOCS_URL_JOB_TIMELINE = DOCS_URL + '/features/job-timeline'; + public static final String ERROR_MESSAGE_NEXT_IN_CHAIN_OUTSIDE_CHAIN = + 'nextInChain() places a job right after the one that is running, so it can only be used from inside a QueueableJob. Outside a job, chain() and enqueue() already run jobs in the order they were added. See ' + + DOCS_URL_JOB_TIMELINE; + public static final String ERROR_MESSAGE_PRIORITY_WITH_NEXT_IN_CHAIN = 'nextInChain() and priority() cannot be combined: a job placed next in the chain runs before any priority.'; + public static final String ERROR_MESSAGE_LINK_KEY_REQUIRED = 'linkExternalRecord(...) needs a key: the identifier of the record in the other system.'; + public static final String ERROR_MESSAGE_LINK_RECORD_OUTSIDE_JOB = + 'Async.linkRecord() adds records to the job that is running, so it can only be called from inside work(). Before the job runs, use forRecord() on the builder. See ' + + DOCS_URL_JOB_TIMELINE; + public static final String WARNING_MAX_RECORD_LINKS_EXCEEDS_CAP = 'QueueableJobSetting__mdt.MaxRecordLinks__c is {0} for "{1}", above the framework cap of {2}, so it was clamped to {2}.'; + public static final String WARNING_RECORD_LINKS_TRUNCATED = 'The job "{1}" was linked to {2} records, above the limit of {0} from QueueableJobSetting__mdt.MaxRecordLinks__c, so only the first {0} were stored.'; + public static final String WARNING_PROGRESS_ROW_NOT_WRITTEN = 'The Enqueued row for "{0}" could not be written: {1}. The job runs anyway and its result row is written when it settles.'; + public static final String WARNING_PROGRESS_ROW_REPLACED = 'The Enqueued row for "{0}" could not be updated: {1}. A new result row was written instead.'; + public static final String WARNING_RECORD_LINKS_NOT_WRITTEN = 'Record links for "{0}" could not be written: {1}.'; + public static final String WARNING_RECORD_LINKS_DML_BUDGET = 'Only {0} of {1} record links were stored for "{2}": the transaction had no DML rows left for the rest.'; + public static final String REQUEUE_SKIPPED_DEPENDENCY_UNMET = 'Depends on "{0}", which required {1} but was {2}, and that job is not being requeued. Requeue both in the same call, or enqueue this job fresh.'; + public static final String REQUEUE_SKIPPED_DEPENDENCY_UNKNOWN = 'Depends on job "{0}", whose result is no longer there, so the dependency cannot be checked. Enqueue this job fresh.'; + public static final String REQUEUE_DEPENDENCY_DROPPED = 'Depended on "{0}", which was {1}, so the replay runs without that dependency.'; + public static final String ERROR_MESSAGE_REQUEUE_PERMISSION = 'Requeue needs the Async Requeue custom permission. Ask an admin for the Async Requeue Access permission set.'; public static final String ERROR_MESSAGE_ASYNC_OPTIONS_AFTER_DELAY = 'Cannot set asyncOptions after delay has been set'; public static final String ERROR_MESSAGE_DELAY_AFTER_ASYNC_OPTIONS = 'Cannot set delay after asyncOptions has been set'; private static final String ERROR_MESSAGE_CHAIN_NULL = 'QueueableChain cannot be null'; @@ -142,6 +165,56 @@ public inherited sharing class QueueableManager { chain.skipJobByCustomJobId(customJobId); } + public void linkRecords(List recordIds) { + QueueableJob runningJob = chain.getCurrentJob(); + if (runningJob == null) { + throw new IllegalArgumentException(ERROR_MESSAGE_LINK_RECORD_OUTSIDE_JOB); + } + runningJob.linkRecords(recordIds); + } + + public void linkExternalRecords(List links) { + QueueableJob runningJob = chain.getCurrentJob(); + if (runningJob == null) { + throw new IllegalArgumentException(ERROR_MESSAGE_LINK_RECORD_OUTSIDE_JOB); + } + runningJob.linkExternalRecords(links); + } + + public static String linkIdentityOf(Async.RecordLink link) { + return (link.source ?? '') + '|' + link.key; + } + + public static Async.Outcome outcomeOf(String status) { + if (status == STATUS_COMPLETED) { + return Async.Outcome.SUCCESS; + } + if (status == STATUS_FAILED) { + return Async.Outcome.FAILURE; + } + return null; + } + + public static Boolean outcomeMatches(Async.Outcome actual, Async.Outcome required) { + return actual != null && (required == Async.Outcome.COMPLETED || actual == required); + } + + public static Schema.SObjectType sobjectTypeOrNull(Id recordId) { + try { + return recordId.getSObjectType(); + } catch (SObjectException unknownPrefix) { + return null; + } + } + + public static List idsOf(List records) { + List ids = new List(); + for (SObject record : records ?? new List()) { + ids.add(record?.Id); + } + return ids; + } + public Async.QueueableJobContext getQueueableJobContext() { Async.QueueableJobContext ctx = new Async.QueueableJobContext(); ctx.currentJob = chain.getCurrentJob(); @@ -184,6 +257,7 @@ public inherited sharing class QueueableManager { } QueueableJob clonedJob = job.cloneJob(); + clonedJob.addedByCustomJobId = job.isFinalizer ? null : chain.getCurrentJob()?.customJobId; chain.addJob(clonedJob); return new Async.Result(clonedJob).setCurrentQueueableChainState(); } @@ -278,14 +352,64 @@ public inherited sharing class QueueableManager { return settingFor(job.className, QueueableJobSetting__mdt.LoggerClass__c); } + public static Boolean trackProgressEnabledFor(QueueableJob job) { + return settingFor(job.className, QueueableJobSetting__mdt.TrackJobProgress__c) == 'Yes'; + } + + public static Integer maxRecordLinksFor(QueueableJob job) { + Decimal configured = numericSettingFor( + job.className, + QueueableJobSetting__mdt.MaxRecordLinks__c + ); + if (configured == null) { + return DEFAULT_MAX_RECORD_LINKS; + } + if (configured <= MAX_RECORD_LINKS_CAP) { + return configured.intValue(); + } + job.recordConfigurationWarning( + String.format( + WARNING_MAX_RECORD_LINKS_EXCEEDS_CAP, + new List{ + String.valueOf(configured.intValue()), + job.className, + String.valueOf(MAX_RECORD_LINKS_CAP) + } + ) + ); + return MAX_RECORD_LINKS_CAP; + } + + private static Decimal numericSettingFor(String jobClassName, SObjectField field) { + Map settings = QueueableJobSettings.jobSettingByName; + Decimal jobSpecific = (Decimal) settings.get(jobClassName)?.get(field); + return jobSpecific ?? (Decimal) settings.get(QUEUEABLE_JOB_SETTING_ALL)?.get(field); + } + public static String settingFor(String jobClassName, SObjectField field) { - Map settings = QueueableChain.jobSettingByName; + Map settings = QueueableJobSettings.jobSettingByName; String jobSpecific = (String) settings.get(jobClassName)?.get(field); return String.isNotBlank(jobSpecific) ? jobSpecific : (String) settings.get(QUEUEABLE_JOB_SETTING_ALL)?.get(field); } + public static Async.Dependency firstUnsatisfiedDependency( + QueueableJob job, + Map outcomeByCustomJobId + ) { + if (job.dependencies == null) { + return null; + } + for (Async.Dependency dependency : job.dependencies) { + Async.Outcome actual = outcomeByCustomJobId.get(dependency.resolvedTargetCustomJobId); + if (!outcomeMatches(actual, dependency.requiredOutcome)) { + return dependency; + } + } + return null; + } + public static Boolean declaresRetryStateHandling(QueueableJob job) { return job.restoreStateOnRetry || job instanceof Async.Retryable; } diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineChains.cls b/force-app/main/default/classes/timeline/AsyncJobTimelineChains.cls new file mode 100644 index 0000000..55a2e50 --- /dev/null +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineChains.cls @@ -0,0 +1,169 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public with sharing class AsyncJobTimelineChains { + private static final Integer LINK_SCAN_LIMIT = 2000; + private static final Datetime ALL_TIME = Datetime.newInstance(1970, 1, 1); + private static final String STATUS_INFLIGHT = 'inflight'; + private static final String STATUS_FAILED = 'failed'; + private static final String STATUS_COMPLETED = 'completed'; + private static final Map CARD_PRECEDENCE = new Map{ + STATUS_INFLIGHT => 0, + STATUS_FAILED => 1, + STATUS_COMPLETED => 2 + }; + private static final Set FAILED_ROW_STATUSES = new Set{ + QueueableManager.STATUS_FAILED, + QueueableManager.STATUS_FRAMEWORK_ERROR, + QueueableManager.STATUS_SKIPPED_CHAIN_STOPPED + }; + + private final Id recordId; + private final Datetime cutoff; + private final List chains; + + public AsyncJobTimelineChains(Id recordId, Integer daysBack) { + this.recordId = recordId; + this.cutoff = daysBack <= 0 ? ALL_TIME : System.now().addDays(-daysBack); + this.chains = newestFirst(recordId, cutoff); + } + + private static List newestFirst(Id recordId, Datetime cutoff) { + List chains = new List(); + for (AggregateResult chain : [ + SELECT ChainId__c chainId, MIN(CreatedDate) started, MIN(Name) firstResult + FROM AsyncResult__c + WHERE + ChainId__c != NULL + AND CreatedDate >= :cutoff + AND Id IN ( + SELECT AsyncResult__c + FROM AsyncResultLink__c + WHERE Key__c = :recordId AND Source__c = :QueueableManager.SOURCE_SALESFORCE + ) + WITH USER_MODE + GROUP BY ChainId__c + LIMIT :LINK_SCAN_LIMIT + ]) { + chains.add(new ChainOrder(chain)); + } + addStatusFromAllRows(chains); + chains.sort(); + return chains; + } + + private static void addStatusFromAllRows(List chains) { + Map chainById = new Map(); + for (ChainOrder chain : chains) { + chainById.put(chain.chainId, chain); + } + for (AggregateResult row : [ + SELECT ChainId__c chainId, Status__c status + FROM AsyncResult__c + WHERE ChainId__c IN :chainById.keySet() + WITH USER_MODE + GROUP BY ChainId__c, Status__c + ]) { + chainById.get((String) row.get('chainId')) + .absorb(chainStatusFor((String) row.get('status'))); + } + } + + private static String chainStatusFor(String rowStatus) { + if (rowStatus == QueueableManager.STATUS_ENQUEUED) { + return STATUS_INFLIGHT; + } + if (FAILED_ROW_STATUSES.contains(rowStatus)) { + return STATUS_FAILED; + } + return STATUS_COMPLETED; + } + + private static Integer cardPrecedenceOf(String chainStatus) { + return CARD_PRECEDENCE.get(chainStatus); + } + + public List idsMatching(String statusFilter, List keepChainIds) { + Set kept = new Set(keepChainIds ?? new List()); + List chainIds = new List(); + for (ChainOrder chain : chains) { + if ( + String.isBlank(statusFilter) || + statusFilter == 'all' || + chain.status == statusFilter || + kept.contains(chain.chainId) + ) { + chainIds.add(chain.chainId); + } + } + return chainIds; + } + + public Map counts() { + Map counts = new Map{ + 'all' => chains.size(), + STATUS_INFLIGHT => 0, + STATUS_FAILED => 0, + STATUS_COMPLETED => 0 + }; + for (ChainOrder chain : chains) { + counts.put(chain.status, counts.get(chain.status) + 1); + } + return counts; + } + + private class ChainOrder implements Comparable { + public String chainId; + public Datetime started; + public String firstResult; + public String status = STATUS_COMPLETED; + + public ChainOrder(AggregateResult chain) { + chainId = (String) chain.get('chainId'); + started = (Datetime) chain.get('started'); + firstResult = (String) chain.get('firstResult') ?? ''; + } + + public void absorb(String rowStatus) { + if (cardPrecedenceOf(rowStatus) < cardPrecedenceOf(status)) { + status = rowStatus; + } + } + + public Integer compareTo(Object other) { + ChainOrder that = (ChainOrder) other; + if (started != that.started) { + return started > that.started ? -1 : 1; + } + return that.firstResult.compareTo(firstResult); + } + } + + public Integer olderCount() { + Set older = new Set(); + for (AsyncResultLink__c link : [ + SELECT AsyncResult__r.ChainId__c + FROM AsyncResultLink__c + WHERE + Key__c = :recordId + AND Source__c = :QueueableManager.SOURCE_SALESFORCE + AND AsyncResult__r.CreatedDate < :cutoff + WITH USER_MODE + LIMIT :LINK_SCAN_LIMIT + ]) { + older.add(link.AsyncResult__r.ChainId__c); + } + return older.size(); + } + + public static List slice(List chainIds, Integer offset, Integer count) { + List page = new List(); + for (Integer i = offset; i < Math.min(chainIds.size(), offset + count); i++) { + page.add(chainIds[i]); + } + return page; + } +} diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineChains.cls-meta.xml b/force-app/main/default/classes/timeline/AsyncJobTimelineChains.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineChains.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineController.cls b/force-app/main/default/classes/timeline/AsyncJobTimelineController.cls new file mode 100644 index 0000000..c9a7ace --- /dev/null +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineController.cls @@ -0,0 +1,258 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + * + * PMD False Positives: + * - ExcessivePublicCount / TooManyFields: the DTOs mirror the result row field by field + **/ +@SuppressWarnings('PMD.ExcessivePublicCount,PMD.TooManyFields') +public with sharing class AsyncJobTimelineController { + public static final String ERROR_MESSAGE_NO_ACCESS = 'You cannot read async job results. Ask an admin for the Async Result Access permission set.'; + private static final String REQUEUE_PERMISSION = 'AsyncRequeue'; + + @AuraEnabled + public static TimelinePage load(AsyncJobTimelineRequest request) { + try { + TimelinePage page = AsyncJobTimelineLoader.pageFor(request); + page.canRequeue = FeatureManagement.checkPermission(REQUEUE_PERMISSION); + return page; + } catch (QueryException queryFailure) { + throw readableError(queryFailure); + } + } + + @AuraEnabled + public static PollResult poll(Id recordId, List chainIds) { + try { + PollResult result = new PollResult(); + result.fingerprint = AsyncJobTimelineLoader.fingerprintOf(recordId, chainIds); + result.apexJobStatusById = AsyncJobTimelineLoader.apexJobStatusesFor( + AsyncJobTimelineLoader.enqueuedJobIdsIn(chainIds) + ); + return result; + } catch (QueryException queryFailure) { + throw readableError(queryFailure); + } + } + + @AuraEnabled + public static RequeueOutcome requeue(List resultIds) { + if (!FeatureManagement.checkPermission(REQUEUE_PERMISSION)) { + throw auraError(QueueableManager.ERROR_MESSAGE_REQUEUE_PERMISSION); + } + Async.RequeueSummary summary; + try { + summary = Async.requeue(new Set(resultIds)); + } catch (Exception requeueFailure) { + throw auraError(requeueFailure.getMessage()); + } + RequeueOutcome outcome = new RequeueOutcome(); + outcome.requeued = summary.requeued; + outcome.skipReasonByResultId = summary.skipReasonByResultId; + outcome.droppedDependencyByResultId = summary.droppedDependencyByResultId; + List replayed = summary.enqueueResult?.queueableChainState?.jobs; + outcome.chainId = replayed == null || replayed.isEmpty() ? null : replayed[0].chainId; + return outcome; + } + + // getMessage() on an AuraHandledException returns "Script-thrown exception" unless the + // message is also set explicitly. + private static AuraHandledException auraError(String message) { + AuraHandledException error = new AuraHandledException(message); + error.setMessage(message); + return error; + } + + // A viewer without field access on a parent or child relationship gets "Variable does not + // exist: tmpVarN" from a user-mode query, not an "Insufficient permissions" message. + private static AuraHandledException readableError(QueryException queryFailure) { + String message = queryFailure.getMessage(); + Boolean accessProblem = + message.contains('Insufficient permissions') || + message.contains('is not supported') || + message.contains('No such column') || + message.contains('tmpVar'); + return auraError(accessProblem ? ERROR_MESSAGE_NO_ACCESS : message); + } + + public class TimelinePage { + @AuraEnabled + public List sequences = new List(); + @AuraEnabled + public Boolean hasMore = false; + @AuraEnabled + public Integer olderChainCount = 0; + @AuraEnabled + public Integer totalChainCount = 0; + @AuraEnabled + public Map counts = new Map(); + @AuraEnabled + public Boolean canRequeue = false; + @AuraEnabled + public String fingerprint; + @AuraEnabled + public Datetime loadedAt; + } + + public class Sequence { + @AuraEnabled + public String chainId; + @AuraEnabled + public String name; + @AuraEnabled + public Datetime startedAt; + @AuraEnabled + public List steps = new List(); + + public Sequence(String chainId) { + this.chainId = chainId; + } + } + + public class Step { + @AuraEnabled + public Id id; + @AuraEnabled + public String name; + @AuraEnabled + public String customJobId; + @AuraEnabled + public String className; + @AuraEnabled + public String jobName; + @AuraEnabled + public String status; + @AuraEnabled + public String apexJobStatus; + @AuraEnabled + public String salesforceJobId; + @AuraEnabled + public Datetime createdAt; + @AuraEnabled + public Datetime startedAt; + @AuraEnabled + public Datetime finishedAt; + @AuraEnabled + public Datetime nextAttemptAt; + @AuraEnabled + public Integer chainSequence; + @AuraEnabled + public Integer priority; + @AuraEnabled + public Integer retryAttempts; + @AuraEnabled + public Integer chunkPage; + @AuraEnabled + public Integer chunkPages; + @AuraEnabled + public Integer delayMinutes; + @AuraEnabled + public String parentCustomJobId; + @AuraEnabled + public String addedByCustomJobId; + @AuraEnabled + public String chunkRunId; + @AuraEnabled + public String exceptionType; + @AuraEnabled + public String exceptionMessage; + @AuraEnabled + public String retryHistory; + @AuraEnabled + public String skipReason; + @AuraEnabled + public String requeueStatus; + @AuraEnabled + public Id requeuedFrom; + @AuraEnabled + public String requeuedFromName; + @AuraEnabled + public Id replayResultId; + @AuraEnabled + public String replayChainId; + @AuraEnabled + public Id dependsOnResult; + @AuraEnabled + public String dependencies; + @AuraEnabled + public List records = new List(); + + public Step( + AsyncResult__c row, + Map apexJobStatusById, + Map nameById + ) { + id = row.Id; + name = row.Name; + customJobId = row.CustomJobId__c; + className = row.ClassName__c; + jobName = row.JobName__c; + status = row.Status__c; + salesforceJobId = row.SalesforceJobId__c; + apexJobStatus = apexJobStatusById.get(row.SalesforceJobId__c); + createdAt = row.CreatedDate; + startedAt = row.StartedAt__c; + finishedAt = row.FinishedAt__c; + nextAttemptAt = row.NextAttemptAt__c; + chainSequence = row.ChainSequence__c?.intValue(); + priority = row.Priority__c?.intValue(); + retryAttempts = row.RetryAttempts__c?.intValue(); + chunkPage = row.ChunkPage__c?.intValue(); + chunkPages = row.ChunkPages__c?.intValue(); + delayMinutes = row.DelayMinutes__c?.intValue(); + parentCustomJobId = row.ParentCustomJobId__c; + addedByCustomJobId = row.AddedByCustomJobId__c; + chunkRunId = row.ChunkRunId__c; + exceptionType = row.ExceptionType__c; + exceptionMessage = row.ExceptionMessage__c; + retryHistory = row.RetryHistory__c; + skipReason = row.SkipReason__c; + requeueStatus = row.RequeueStatus__c; + requeuedFrom = row.RequeuedFrom__c; + requeuedFromName = row.RequeuedFrom__r?.Name; + dependsOnResult = row.DependsOnResult__c; + dependencies = row.Dependencies__c; + for (AsyncResultLink__c link : row.Links__r) { + records.add(new LinkedRecord(link, nameById)); + } + } + } + + public class LinkedRecord { + @AuraEnabled + public String key; + @AuraEnabled + public String source; + @AuraEnabled + public String objectName; + @AuraEnabled + public String name; + + public LinkedRecord(AsyncResultLink__c link, Map nameById) { + key = link.Key__c; + source = link.Source__c; + objectName = link.ObjectName__c; + name = nameById.get(AsyncJobTimelineLoader.salesforceIdOf(link)) ?? link.Key__c; + } + } + + public class PollResult { + @AuraEnabled + public String fingerprint; + @AuraEnabled + public Map apexJobStatusById = new Map(); + } + + public class RequeueOutcome { + @AuraEnabled + public List requeued = new List(); + @AuraEnabled + public Map skipReasonByResultId = new Map(); + @AuraEnabled + public Map droppedDependencyByResultId = new Map(); + @AuraEnabled + public String chainId; + } +} diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineController.cls-meta.xml b/force-app/main/default/classes/timeline/AsyncJobTimelineController.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineController.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineControllerTest.cls b/force-app/main/default/classes/timeline/AsyncJobTimelineControllerTest.cls new file mode 100644 index 0000000..f8d9203 --- /dev/null +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineControllerTest.cls @@ -0,0 +1,752 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + * + * PMD False Positives: + * - ApexAssertionsShouldIncludeMessage: not every assertion needs a message + * - ExcessiveParameterList: the seed helpers take the row shape as arguments + * - EmptyStatementBlock: a queueable that does nothing is the fixture + * - CyclomaticComplexity: flat tests, the class total only counts how many there are + **/ +@SuppressWarnings( + 'PMD.ApexAssertionsShouldIncludeMessage,PMD.ExcessiveParameterList,PMD.EmptyStatementBlock,PMD.CyclomaticComplexity' +) +@IsTest +private class AsyncJobTimelineControllerTest { + private static final String RESULT_ACCESS = 'AsyncResultAccess'; + private static final String REQUEUE_ACCESS = 'AsyncRequeueAccess'; + private static final String OPERATOR_ALIAS = 'tlop'; + + @TestSetup + private static void createOperator() { + userWith(OPERATOR_ALIAS, new List{ RESULT_ACCESS, REQUEUE_ACCESS }); + } + + @IsTest + private static void shouldLoadSequencesNewestFirstWithinTheWindow() { + Account order = new Account(Name = 'Order'); + insert order; + seedChain(order, 'recent', 'Recent run', 2, System.now().addHours(-1)); + seedChain(order, 'earlier', 'Earlier run', 3, System.now().addDays(-2)); + seedChain(order, 'old', 'Old run', 1, System.now().addDays(-10)); + + AsyncJobTimelineController.TimelinePage page = loadAsOperator(order.Id, 7, 10, 0); + + Assert.areEqual(2, page.sequences.size(), 'The 10-day-old chain is outside the window.'); + Assert.areEqual('recent', page.sequences[0].chainId); + Assert.areEqual('Recent run', page.sequences[0].name); + Assert.areEqual('earlier', page.sequences[1].chainId); + Assert.areEqual(3, page.sequences[1].steps.size()); + Assert.areEqual(1, page.sequences[1].steps[0].chainSequence); + Assert.areEqual(3, page.sequences[1].steps[2].chainSequence); + Assert.areEqual(1, page.olderChainCount); + Assert.isFalse(page.hasMore); + Assert.isNotNull(page.fingerprint); + Assert.areEqual('Order', page.sequences[0].steps[0].records[0].name); + Assert.areEqual('Account', page.sequences[0].steps[0].records[0].objectName); + } + + @IsTest + private static void shouldNotLiftARunningChainAboveNewerOnes() { + Account order = new Account(Name = 'Order'); + insert order; + seedChain(order, 'newer', 'Newer run', 1, System.now().addHours(-1)); + seedChain(order, 'older', 'Older run', 1, System.now().addDays(-2)); + List live = seedChain( + order, + 'live', + 'Live run', + 2, + System.now().addDays(-5) + ); + live[1].Status__c = QueueableManager.STATUS_ENQUEUED; + update live[1]; + + AsyncJobTimelineController.TimelinePage page = loadAsOperator(order.Id, 7, 10, 0); + + Assert.areEqual(new List{ 'newer', 'older', 'live' }, chainIdsOf(page)); + Assert.areEqual(3, page.totalChainCount); + } + + @IsTest + private static void shouldNotMoveAChainWhenItFinishes() { + Account order = new Account(Name = 'Order'); + insert order; + List recent = seedChain( + order, + 'recent', + 'Recent run', + 1, + System.now().addHours(-1) + ); + List late = seedChain( + order, + 'late', + 'Started early, finished last', + 2, + System.now().addDays(-2) + ); + recent[0].FinishedAt__c = System.now().addHours(-1); + late[1].FinishedAt__c = System.now(); + update new List{ recent[0], late[1] }; + + AsyncJobTimelineController.TimelinePage page = loadAsOperator(order.Id, 7, 10, 0); + + Assert.areEqual(new List{ 'recent', 'late' }, chainIdsOf(page)); + } + + @IsTest + private static void shouldKeepAWatchedChainInTheFilterAfterItFinishes() { + Account order = new Account(Name = 'Order'); + insert order; + seedChain(order, 'watched', 'Finished while watched', 1, System.now().addHours(-1)); + seedChain(order, 'other', 'Finished earlier', 1, System.now().addHours(-2)); + List live = seedChain( + order, + 'live', + 'Live run', + 2, + System.now().addHours(-3) + ); + live[1].Status__c = QueueableManager.STATUS_ENQUEUED; + update live[1]; + + AsyncJobTimelineController.TimelinePage page = loadInFlightKeeping( + order.Id, + new List{ 'watched' } + ); + + Assert.areEqual(new List{ 'watched', 'live' }, chainIdsOf(page)); + Assert.areEqual(2, page.totalChainCount); + Assert.areEqual(1, page.counts.get('inflight'), 'The chip counts what still runs.'); + } + + @IsTest + private static void shouldNotShowAKeptChainTheRecordIsNotLinkedTo() { + Account order = new Account(Name = 'Order'); + Account stranger = new Account(Name = 'Stranger'); + insert new List{ order, stranger }; + seedChain(stranger, 'foreign', 'Linked elsewhere', 1, System.now().addHours(-1)); + seedChain(order, 'own', 'Own run', 1, System.now().addHours(-2)); + + AsyncJobTimelineController.TimelinePage page = loadInFlightKeeping( + order.Id, + new List{ 'foreign' } + ); + + Assert.areEqual(new List(), chainIdsOf(page)); + } + + @IsTest + private static void shouldKeepEnqueueOrderForChainsStartedInTheSameSecond() { + Account order = new Account(Name = 'Order'); + insert order; + Datetime sameSecond = System.now().addHours(-1); + seedChain(order, 'first', 'First', 1, sameSecond); + seedChain(order, 'second', 'Second', 1, sameSecond); + seedChain(order, 'third', 'Third', 1, sameSecond); + + AsyncJobTimelineController.TimelinePage page = loadAsOperator(order.Id, 7, 10, 0); + + Assert.areEqual(new List{ 'third', 'second', 'first' }, chainIdsOf(page)); + } + + @IsTest + private static void shouldCountChainsByStatusAcrossTheWholeRangeAndFilterByIt() { + Account order = new Account(Name = 'Order'); + insert order; + seedChain(order, 'done', 'Done run', 1, System.now().addHours(-1)); + List failed = seedChain( + order, + 'bad', + 'Bad run', + 2, + System.now().addHours(-2) + ); + failed[1].Status__c = QueueableManager.STATUS_FAILED; + update failed[1]; + List live = seedChain( + order, + 'live', + 'Live run', + 2, + System.now().addHours(-3) + ); + live[1].Status__c = QueueableManager.STATUS_ENQUEUED; + update live[1]; + + AsyncJobTimelineController.TimelinePage firstOnly = loadAsOperator(order.Id, 7, 1, 0); + AsyncJobTimelineController.TimelinePage onlyFailed = loadAsOperator( + order.Id, + 7, + 10, + 0, + 'failed' + ); + AsyncJobTimelineController.TimelinePage onlyDone = loadAsOperator( + order.Id, + 7, + 10, + 0, + 'completed' + ); + + Assert.areEqual(1, firstOnly.sequences.size()); + Assert.areEqual(3, firstOnly.counts.get('all'), 'Counts cover the range, not the page.'); + Assert.areEqual(1, firstOnly.counts.get('inflight')); + Assert.areEqual(1, firstOnly.counts.get('failed')); + Assert.areEqual(1, firstOnly.counts.get('completed')); + Assert.areEqual(new List{ 'bad' }, chainIdsOf(onlyFailed)); + Assert.areEqual(1, onlyFailed.totalChainCount); + Assert.areEqual(new List{ 'done' }, chainIdsOf(onlyDone)); + } + + @IsTest + private static void shouldLinkARequeuedRowToItsReplay() { + Account order = new Account(Name = 'Order'); + insert order; + List source = seedChain( + order, + 'source', + 'Source', + 1, + System.now().addHours(-2) + ); + source[0].Status__c = QueueableManager.STATUS_FAILED; + source[0].RequeueStatus__c = 'Requeued'; + update source[0]; + List replay = seedChain( + order, + 'replay', + 'Replay', + 1, + System.now().addHours(-1) + ); + replay[0].RequeuedFrom__c = source[0].Id; + update replay[0]; + + AsyncJobTimelineController.TimelinePage page = loadAsOperator(order.Id, 7, 10, 0); + + AsyncJobTimelineController.Sequence sourceSequence = page.sequences[1]; + Assert.areEqual('source', sourceSequence.chainId); + Assert.areEqual(replay[0].Id, sourceSequence.steps[0].replayResultId); + Assert.areEqual('replay', sourceSequence.steps[0].replayChainId); + } + + @IsTest + private static void shouldLoadAllTimeWhenDaysBackIsZero() { + Account order = new Account(Name = 'Order'); + insert order; + seedChain(order, 'ancient', 'Ancient run', 1, System.now().addDays(-400)); + seedChain(order, 'recent', 'Recent run', 1, System.now().addHours(-1)); + + AsyncJobTimelineController.TimelinePage page = loadAsOperator(order.Id, 0, 10, 0); + + Assert.areEqual(new List{ 'recent', 'ancient' }, chainIdsOf(page)); + Assert.areEqual(0, page.olderChainCount); + Assert.areEqual(2, page.totalChainCount); + } + + @IsTest + private static void shouldPageByChainsAndCountOlderOnes() { + Account order = new Account(Name = 'Order'); + insert order; + seedChain(order, 'one', null, 1, System.now().addDays(-2)); + seedChain(order, 'two', null, 1, System.now().addDays(-3)); + seedChain(order, 'three', null, 1, System.now().addDays(-4)); + seedChain(order, 'older-a', null, 1, System.now().addDays(-9)); + seedChain(order, 'older-b', null, 1, System.now().addDays(-12)); + + AsyncJobTimelineController.TimelinePage first = loadAsOperator(order.Id, 7, 2, 0); + AsyncJobTimelineController.TimelinePage second = loadAsOperator(order.Id, 7, 2, 2); + AsyncJobTimelineController.TimelinePage onlyOld = loadAsOperator(order.Id, 1, 10, 0); + + Assert.areEqual(new List{ 'one', 'two' }, chainIdsOf(first)); + Assert.isTrue(first.hasMore); + Assert.areEqual(new List{ 'three' }, chainIdsOf(second)); + Assert.isFalse(second.hasMore); + Assert.areEqual(2, first.olderChainCount); + Assert.isTrue(onlyOld.sequences.isEmpty(), 'Nothing in the last day.'); + Assert.areEqual(5, onlyOld.olderChainCount, 'Everything is older than a day.'); + } + + @IsTest + private static void shouldNameAnUnnamedSequenceAfterItsFirstJob() { + Account order = new Account(Name = 'Order'); + insert order; + seedChain(order, 'unnamed', null, 2, System.now()); + + AsyncJobTimelineController.TimelinePage page = loadAsOperator(order.Id, 7, 10, 0); + + Assert.areEqual('Step 1', page.sequences[0].name, 'The job name of the first step.'); + } + + @IsTest + private static void shouldJoinApexJobStatusForEnqueuedRows() { + Account order = new Account(Name = 'Order'); + insert order; + Id apexJobId = System.enqueueJob(new NoopQueueable()); + AsyncResult__c waiting = seedChain(order, 'live', null, 1, System.now())[0]; + waiting.Status__c = QueueableManager.STATUS_ENQUEUED; + waiting.SalesforceJobId__c = apexJobId; + update waiting; + + AsyncJobTimelineController.TimelinePage page = loadAsOperator(order.Id, 7, 10, 0); + AsyncJobTimelineController.PollResult polled = pollAsOperator(order.Id, 'live'); + + Assert.areEqual('Queued', page.sequences[0].steps[0].apexJobStatus); + Assert.areEqual('Queued', polled.apexJobStatusById.get(apexJobId)); + } + + @IsTest + private static void shouldShowAnExternalLinkWithItsKeyAsTheName() { + Account order = new Account(Name = 'Order'); + insert order; + AsyncResult__c step = seedChain(order, 'ext', null, 1, System.now())[0]; + insert new AsyncResultLink__c( + AsyncResult__c = step.Id, + Key__c = '4711', + Source__c = 'NetSuite', + ObjectName__c = 'Customer' + ); + + AsyncJobTimelineController.TimelinePage page = loadAsOperator(order.Id, 7, 10, 0); + + AsyncJobTimelineController.LinkedRecord external = page.sequences[0].steps[0].records[1]; + Assert.areEqual('4711', external.key); + Assert.areEqual('NetSuite', external.source); + Assert.areEqual('Customer', external.objectName); + Assert.areEqual('4711', external.name, 'No Salesforce name to look up.'); + } + + @IsTest + private static void shouldResolveLinkedRecordNamesPerObjectType() { + Account order = new Account(Name = 'Order'); + insert order; + Id taskId = Id.valueOf(Task.SObjectType.getDescribe().getKeyPrefix() + '000000000001'); + AsyncResult__c step = seedChain(order, 'links', null, 1, System.now())[0]; + insert new AsyncResultLink__c( + AsyncResult__c = step.Id, + Key__c = taskId, + Source__c = QueueableManager.SOURCE_SALESFORCE, + ObjectName__c = 'Task' + ); + + AsyncJobTimelineController.TimelinePage page = loadAsOperator(order.Id, 7, 10, 0); + + Map nameById = new Map(); + for (AsyncJobTimelineController.LinkedRecord linked : page.sequences[0].steps[0].records) { + nameById.put(linked.key, linked.name); + } + Assert.areEqual('Order', nameById.get(order.Id)); + Assert.areEqual( + String.valueOf(taskId), + nameById.get(taskId), + 'A record without a readable name shows its id.' + ); + } + + @IsTest + private static void shouldKeepTheFingerprintWhenNothingMoved() { + Account order = new Account(Name = 'Order'); + insert order; + seedChain(order, 'stable', null, 2, System.now()); + + AsyncJobTimelineController.TimelinePage page = loadAsOperator(order.Id, 7, 10, 0); + AsyncJobTimelineController.PollResult polled = pollAsOperator(order.Id, 'stable'); + + Assert.areEqual(page.fingerprint, polled.fingerprint); + } + + @IsTest + private static void shouldChangeTheFingerprintWhenARowIsUpdated() { + Account order = new Account(Name = 'Order'); + insert order; + AsyncResult__c step = seedChain(order, 'moving', null, 1, System.now().addMinutes(-5))[0]; + AsyncJobTimelineController.TimelinePage page = loadAsOperator(order.Id, 7, 10, 0); + + step.Status__c = QueueableManager.STATUS_FAILED; + update step; + AsyncJobTimelineController.PollResult polled = pollAsOperator(order.Id, 'moving'); + + Assert.areNotEqual(page.fingerprint, polled.fingerprint); + } + + @IsTest + private static void shouldChangeTheFingerprintWhenANewChainIsLinked() { + Account order = new Account(Name = 'Order'); + insert order; + seedChain(order, 'first', null, 1, System.now()); + AsyncJobTimelineController.TimelinePage page = loadAsOperator(order.Id, 7, 10, 0); + + seedChain(order, 'second', null, 1, System.now()); + AsyncJobTimelineController.PollResult polled = pollAsOperator(order.Id, 'first'); + + Assert.areNotEqual(page.fingerprint, polled.fingerprint); + } + + @IsTest + private static void shouldRejectRequeueWithoutTheCustomPermission() { + User viewer = userWith('tlvw', new List{ RESULT_ACCESS }); + Account order = new Account(Name = 'Order'); + insert order; + AsyncResult__c step = seedChain(order, 'failed', null, 1, System.now())[0]; + String message; + + System.runAs(viewer) { + try { + AsyncJobTimelineController.requeue(new List{ step.Id }); + } catch (AuraHandledException ex) { + message = ex.getMessage(); + } + } + + Assert.areEqual(QueueableManager.ERROR_MESSAGE_REQUEUE_PERMISSION, message); + } + + @IsTest + private static void shouldRequeueWithTheCustomPermission() { + User operator = operator(); + Account order = new Account(Name = 'Order'); + insert order; + AsyncResult__c step = seedChain(order, 'failed', null, 1, System.now())[0]; + TimelineRequeueJob job = new TimelineRequeueJob(); + job.tag = 'REPLAYED'; + step.ClassName__c = job.className; + step.JobPayload__c = JobPayload.forJob(job); + step.PayloadSize__c = step.JobPayload__c.length(); + step.RequeueStatus__c = AsyncRequeue.STATUS_STORED; + update step; + + AsyncJobTimelineController.RequeueOutcome outcome; + System.runAs(operator) { + Test.startTest(); + outcome = AsyncJobTimelineController.requeue(new List{ step.Id }); + Test.stopTest(); + } + + Assert.areEqual(new List{ step.Id }, outcome.requeued); + Assert.isNotNull(outcome.chainId, 'The new chain, so the UI can scroll to it.'); + Assert.areEqual(1, [SELECT COUNT() FROM Account WHERE Name = 'REPLAYED']); + } + + @IsTest + private static void shouldExplainMissingAccessToResults() { + User outsider = userWith('tlout', new List()); + Account order = new Account(Name = 'Order'); + insert order; + seedChain(order, 'hidden', null, 1, System.now()); + String message; + + System.runAs(outsider) { + try { + AsyncJobTimelineController.load(request(order.Id, 7, 10, 0, null)); + } catch (AuraHandledException ex) { + message = ex.getMessage(); + } + } + + Assert.areEqual(AsyncJobTimelineController.ERROR_MESSAGE_NO_ACCESS, message); + } + + private static List seedChain( + Account linked, + String chainId, + String chainName, + Integer steps, + Datetime createdAt + ) { + List rows = new List(); + for (Integer i = 1; i <= steps; i++) { + rows.add( + new AsyncResult__c( + ChainId__c = chainId, + ChainName__c = chainName, + ChainSequence__c = i, + CustomJobId__c = chainId + '-' + i, + JobName__c = 'Step ' + i, + ClassName__c = 'SeedJob', + Status__c = QueueableManager.STATUS_COMPLETED + ) + ); + } + insert rows; + List links = new List(); + for (AsyncResult__c row : rows) { + Test.setCreatedDate(row.Id, createdAt); + links.add( + new AsyncResultLink__c( + AsyncResult__c = row.Id, + Key__c = linked.Id, + Source__c = QueueableManager.SOURCE_SALESFORCE, + ObjectName__c = 'Account' + ) + ); + } + insert links; + return rows; + } + + private static List chainIdsOf(AsyncJobTimelineController.TimelinePage page) { + List chainIds = new List(); + for (AsyncJobTimelineController.Sequence sequence : page.sequences) { + chainIds.add(sequence.chainId); + } + return chainIds; + } + + private static AsyncJobTimelineController.TimelinePage loadInFlightKeeping( + Id recordId, + List keepChainIds + ) { + AsyncJobTimelineRequest request = new AsyncJobTimelineRequest(); + request.recordId = recordId; + request.daysBack = 7; + request.chainLimit = 10; + request.chainOffset = 0; + request.statusFilter = 'inflight'; + request.keepChainIds = keepChainIds; + AsyncJobTimelineController.TimelinePage page; + System.runAs(operator()) { + page = AsyncJobTimelineController.load(request); + } + return page; + } + + @IsTest + static void viewerPermissionSetOpensTheCardController() { + Id viewerPermissionSetId = [ + SELECT Id + FROM PermissionSet + WHERE Name = 'AsyncResultAccess' + LIMIT 1 + ] + .Id; + Id controllerClassId = [ + SELECT Id + FROM ApexClass + WHERE Name = 'AsyncJobTimelineController' + LIMIT 1 + ] + .Id; + + List classAccess = [ + SELECT Id + FROM SetupEntityAccess + WHERE ParentId = :viewerPermissionSetId AND SetupEntityId = :controllerClassId + ]; + + Assert.areEqual( + 1, + classAccess.size(), + 'A viewer without class access cannot call load at all' + ); + } + + @IsTest + private static void shouldTellAViewerWithoutAccessWhenPolling() { + User outsider = userWith('tlpol', new List()); + Account order = new Account(Name = 'Order'); + insert order; + String message; + + System.runAs(outsider) { + try { + AsyncJobTimelineController.poll(order.Id, new List{ 'chain' }); + } catch (AuraHandledException ex) { + message = ex.getMessage(); + } + } + + Assert.areEqual(AsyncJobTimelineController.ERROR_MESSAGE_NO_ACCESS, message); + } + + @IsTest + private static void shouldReportARequeueThatThrowsWithItsOwnMessage() { + String message; + + System.runAs(operator()) { + try { + AsyncJobTimelineController.requeue(null); + } catch (AuraHandledException ex) { + message = ex.getMessage(); + } + } + + Assert.isNotNull(message); + Assert.areNotEqual('Script-thrown exception', message); + } + + @IsTest + private static void shouldNameTheRecordsItCanAndSkipAPrefixWithNoType() { + Account order = new Account(Name = 'Order'); + insert order; + Id noApexType = idOfPrefix(CaseComment.SObjectType.getDescribe().getKeyPrefix()); + + Map nameById = AsyncJobTimelineNames.of(new Set{ order.Id, noApexType }); + + Assert.areEqual(new Map{ order.Id => 'Order' }, nameById); + } + + @IsTest + private static void shouldSkipATypeWithNoNameField() { + Account order = new Account(Name = 'Order'); + insert order; + Id noNameField = idOfPrefix(AccountShare.SObjectType.getDescribe().getKeyPrefix()); + + Map nameById = AsyncJobTimelineNames.of(new Set{ order.Id, noNameField }); + + Assert.areEqual(new Map{ order.Id => 'Order' }, nameById); + } + + @IsTest + private static void shouldLookUpNamesForAtMostTenTypes() { + Set oneIdPerType = new Set(); + for ( + Schema.SObjectType type : new List{ + Account.SObjectType, + Contact.SObjectType, + Lead.SObjectType, + Opportunity.SObjectType, + Case.SObjectType, + User.SObjectType, + Campaign.SObjectType, + Product2.SObjectType, + Pricebook2.SObjectType, + Contract.SObjectType, + Order.SObjectType + } + ) { + oneIdPerType.add(idOfPrefix(type.getDescribe().getKeyPrefix())); + } + + Integer queriesBefore = Limits.getQueries(); + AsyncJobTimelineNames.of(oneIdPerType); + + Assert.areEqual(10, Limits.getQueries() - queriesBefore); + } + + @IsTest + private static void shouldReturnNoNameForATypeTheViewerCannotRead() { + User outsider = userWith('tlnam', new List()); + AsyncResult__c row = new AsyncResult__c(Status__c = QueueableManager.STATUS_COMPLETED); + insert row; + Set ids = new Set{ row.Id }; + Map seenByOutsider; + + System.runAs(outsider) { + seenByOutsider = AsyncJobTimelineNames.of(ids); + } + + Assert.isTrue(AsyncJobTimelineNames.of(ids).containsKey(row.Id)); + Assert.isTrue(seenByOutsider.isEmpty()); + } + + private static Id idOfPrefix(String keyPrefix) { + return Id.valueOf(keyPrefix + '1'.leftPad(12, '0')); + } + + private static User operator() { + return [SELECT Id FROM User WHERE Alias = :OPERATOR_ALIAS LIMIT 1]; + } + + private static AsyncJobTimelineController.TimelinePage loadAsOperator( + Id recordId, + Integer daysBack, + Integer chainLimit, + Integer chainOffset + ) { + return loadAsOperator(recordId, daysBack, chainLimit, chainOffset, null); + } + + private static AsyncJobTimelineController.TimelinePage loadAsOperator( + Id recordId, + Integer daysBack, + Integer chainLimit, + Integer chainOffset, + String statusFilter + ) { + AsyncJobTimelineController.TimelinePage page; + System.runAs(operator()) { + page = AsyncJobTimelineController.load( + request(recordId, daysBack, chainLimit, chainOffset, statusFilter) + ); + } + return page; + } + + private static AsyncJobTimelineRequest request( + Id recordId, + Integer daysBack, + Integer chainLimit, + Integer chainOffset, + String statusFilter + ) { + AsyncJobTimelineRequest request = new AsyncJobTimelineRequest(); + request.recordId = recordId; + request.daysBack = daysBack; + request.chainLimit = chainLimit; + request.chainOffset = chainOffset; + request.statusFilter = statusFilter; + return request; + } + + private static AsyncJobTimelineController.PollResult pollAsOperator( + Id recordId, + String chainId + ) { + AsyncJobTimelineController.PollResult polled; + System.runAs(operator()) { + polled = AsyncJobTimelineController.poll(recordId, new List{ chainId }); + } + return polled; + } + + private static User userWith(String alias, List permissionSetNames) { + Profile standard = [SELECT Id FROM Profile WHERE Name = 'Standard User']; + User created = new User( + Alias = alias, + Email = 'timeline@example.com', + EmailEncodingKey = 'UTF-8', + LastName = 'Timeline', + LanguageLocaleKey = 'en_US', + LocaleSidKey = 'en_US', + ProfileId = standard.Id, + TimeZoneSidKey = 'GMT', + Username = alias + '.' + System.now().getTime() + '@example.com' + ); + System.runAs(new User(Id = UserInfo.getUserId())) { + insert created; + List assignments = new List(); + for (PermissionSet permissionSet : [ + SELECT Id + FROM PermissionSet + WHERE Name IN :permissionSetNames + ]) { + assignments.add( + new PermissionSetAssignment( + AssigneeId = created.Id, + PermissionSetId = permissionSet.Id + ) + ); + } + insert assignments; + } + return created; + } + + public class NoopQueueable implements Queueable { + public void execute(QueueableContext ctx) { + } + } + + public class TimelineRequeueJob extends QueueableJob { + public String tag; + + public override void work() { + insert new Account(Name = tag); + } + } +} diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineControllerTest.cls-meta.xml b/force-app/main/default/classes/timeline/AsyncJobTimelineControllerTest.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineControllerTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineLoader.cls b/force-app/main/default/classes/timeline/AsyncJobTimelineLoader.cls new file mode 100644 index 0000000..7d6c0b4 --- /dev/null +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineLoader.cls @@ -0,0 +1,235 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public with sharing class AsyncJobTimelineLoader { + private static final Integer DEFAULT_DAYS_BACK = 7; + private static final Integer DEFAULT_CHAIN_LIMIT = 10; + + public static AsyncJobTimelineController.TimelinePage pageFor(AsyncJobTimelineRequest request) { + Id recordId = request.recordId; + Integer daysBack = request.daysBack ?? DEFAULT_DAYS_BACK; + Integer chainLimit = request.chainLimit ?? DEFAULT_CHAIN_LIMIT; + Integer chainOffset = request.chainOffset ?? 0; + String statusFilter = request.statusFilter; + AsyncJobTimelineChains chains = new AsyncJobTimelineChains(recordId, daysBack); + List chainIds = chains.idsMatching(statusFilter, request.keepChainIds); + List pageChainIds = AsyncJobTimelineChains.slice(chainIds, chainOffset, chainLimit); + + AsyncJobTimelineController.TimelinePage page = new AsyncJobTimelineController.TimelinePage(); + page.hasMore = chainIds.size() > chainOffset + chainLimit; + page.totalChainCount = chainIds.size(); + page.counts = chains.counts(); + page.olderChainCount = chains.olderCount(); + page.sequences = sequencesOf(pageChainIds); + page.fingerprint = fingerprintOf(recordId, pageChainIds); + page.loadedAt = System.now(); + return page; + } + + private static List sequencesOf(List chainIds) { + Map byChain = new Map(); + for (String chainId : chainIds) { + byChain.put(chainId, new AsyncJobTimelineController.Sequence(chainId)); + } + if (chainIds.isEmpty()) { + return byChain.values(); + } + List rows = rowsOf(chainIds); + Map apexJobStatusById = apexJobStatusesFor(enqueuedJobIdsOf(rows)); + Map nameById = AsyncJobTimelineNames.of(linkedRecordIdsOf(rows)); + Map replayBySourceId = replaysOf(rows); + for (AsyncResult__c row : rows) { + AsyncJobTimelineController.Sequence sequence = byChain.get(row.ChainId__c); + AsyncJobTimelineController.Step step = new AsyncJobTimelineController.Step( + row, + apexJobStatusById, + nameById + ); + AsyncResult__c replay = replayBySourceId.get(row.Id); + step.replayResultId = replay?.Id; + step.replayChainId = replay?.ChainId__c; + sequence.steps.add(step); + sequence.name = sequence.name ?? row.ChainName__c; + sequence.startedAt = earlier(sequence.startedAt, row.CreatedDate); + } + List ordered = new List(); + for (String chainId : chainIds) { + AsyncJobTimelineController.Sequence sequence = byChain.get(chainId); + sequence.name = sequence.name ?? firstStepNameOf(sequence); + ordered.add(sequence); + } + return ordered; + } + + private static List rowsOf(List chainIds) { + return [ + SELECT + Id, + Name, + CustomJobId__c, + ChainId__c, + ChainName__c, + ChainSequence__c, + ClassName__c, + JobName__c, + Status__c, + SalesforceJobId__c, + CreatedDate, + StartedAt__c, + FinishedAt__c, + NextAttemptAt__c, + Priority__c, + RetryAttempts__c, + RetryHistory__c, + ChunkRunId__c, + ChunkPage__c, + ChunkPages__c, + DelayMinutes__c, + ParentCustomJobId__c, + AddedByCustomJobId__c, + ExceptionType__c, + ExceptionMessage__c, + SkipReason__c, + RequeueStatus__c, + RequeuedFrom__c, + RequeuedFrom__r.Name, + DependsOnResult__c, + Dependencies__c, + (SELECT Key__c, Source__c, ObjectName__c FROM Links__r ORDER BY CreatedDate, Id) + FROM AsyncResult__c + WHERE ChainId__c IN :chainIds + WITH USER_MODE + ORDER BY ChainSequence__c NULLS LAST, CreatedDate + ]; + } + + private static Map replaysOf(List rows) { + Set requeuedIds = new Set(); + for (AsyncResult__c row : rows) { + if (row.RequeueStatus__c == AsyncRequeue.STATUS_REQUEUED) { + requeuedIds.add(row.Id); + } + } + Map replayBySourceId = new Map(); + if (requeuedIds.isEmpty()) { + return replayBySourceId; + } + for (AsyncResult__c replay : [ + SELECT Id, ChainId__c, RequeuedFrom__c + FROM AsyncResult__c + WHERE RequeuedFrom__c IN :requeuedIds + WITH USER_MODE + ORDER BY CreatedDate DESC + ]) { + if (!replayBySourceId.containsKey(replay.RequeuedFrom__c)) { + replayBySourceId.put(replay.RequeuedFrom__c, replay); + } + } + return replayBySourceId; + } + + private static Set enqueuedJobIdsOf(List rows) { + Set jobIds = new Set(); + for (AsyncResult__c row : rows) { + if ( + row.Status__c == QueueableManager.STATUS_ENQUEUED && + row.SalesforceJobId__c != null + ) { + jobIds.add(row.SalesforceJobId__c); + } + } + return jobIds; + } + + public static Set enqueuedJobIdsIn(List chainIds) { + Set jobIds = new Set(); + for (AsyncResult__c row : [ + SELECT SalesforceJobId__c + FROM AsyncResult__c + WHERE + ChainId__c IN :chainIds + AND Status__c = :QueueableManager.STATUS_ENQUEUED + AND SalesforceJobId__c != NULL + WITH USER_MODE + ]) { + jobIds.add(row.SalesforceJobId__c); + } + return jobIds; + } + + public static Map apexJobStatusesFor(Set jobIds) { + Map statusById = new Map(); + if (jobIds.isEmpty()) { + return statusById; + } + for (AsyncApexJob job : [ + SELECT Id, Status + FROM AsyncApexJob + WHERE Id IN :jobIds + WITH USER_MODE + ]) { + statusById.put(job.Id, job.Status); + } + return statusById; + } + + private static Set linkedRecordIdsOf(List rows) { + Set recordIds = new Set(); + for (AsyncResult__c row : rows) { + for (AsyncResultLink__c link : row.Links__r) { + Id recordId = salesforceIdOf(link); + if (recordId != null) { + recordIds.add(recordId); + } + } + } + return recordIds; + } + + public static Id salesforceIdOf(AsyncResultLink__c link) { + Boolean salesforceId = + link.Source__c == QueueableManager.SOURCE_SALESFORCE && link.Key__c instanceof Id; + return salesforceId ? (Id) link.Key__c : null; + } + + public static String fingerprintOf(Id recordId, List chainIds) { + List parts = new List(); + for (AggregateResult statusCount : [ + SELECT Status__c status, COUNT(Id) total, MAX(LastModifiedDate) touched + FROM AsyncResult__c + WHERE ChainId__c IN :chainIds + WITH USER_MODE + GROUP BY Status__c + ORDER BY Status__c + ]) { + Datetime touched = (Datetime) statusCount.get('touched'); + parts.add( + statusCount.get('status') + + ':' + + statusCount.get('total') + + ':' + + (touched == null ? '' : String.valueOf(touched.getTime())) + ); + } + Integer links = [ + SELECT COUNT() + FROM AsyncResultLink__c + WHERE Key__c = :recordId AND Source__c = :QueueableManager.SOURCE_SALESFORCE + WITH USER_MODE + ]; + parts.add('links:' + links); + return String.join(parts, '|'); + } + + private static Datetime earlier(Datetime first, Datetime second) { + return first == null || second < first ? second : first; + } + + private static String firstStepNameOf(AsyncJobTimelineController.Sequence sequence) { + AsyncJobTimelineController.Step first = sequence.steps[0]; + return first.jobName ?? first.className; + } +} diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineLoader.cls-meta.xml b/force-app/main/default/classes/timeline/AsyncJobTimelineLoader.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineLoader.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineNames.cls b/force-app/main/default/classes/timeline/AsyncJobTimelineNames.cls new file mode 100644 index 0000000..f221403 --- /dev/null +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineNames.cls @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public with sharing class AsyncJobTimelineNames { + private static final Integer MAX_NAME_LOOKUP_TYPES = 10; + + public static Map of(Set recordIds) { + Map nameById = new Map(); + Map> idsByType = new Map>(); + for (Id recordId : recordIds) { + Schema.SObjectType type = QueueableManager.sobjectTypeOrNull(recordId); + if (type == null) { + continue; + } + Set ids = idsByType.get(type) ?? new Set(); + ids.add(recordId); + idsByType.put(type, ids); + } + Integer typesLookedUp = 0; + for (Schema.SObjectType type : idsByType.keySet()) { + if (typesLookedUp++ >= MAX_NAME_LOOKUP_TYPES) { + break; + } + nameById.putAll(readableNamesOfOneType(type, idsByType.get(type))); + } + return nameById; + } + + private static Map readableNamesOfOneType(Schema.SObjectType type, Set ids) { + Map nameById = new Map(); + String nameField = nameFieldOf(type); + if (nameField == null) { + return nameById; + } + try { + for ( + SObject record : Database.queryWithBinds( + 'SELECT Id, ' + + nameField + + ' FROM ' + + type.getDescribe().getName() + + ' WHERE Id IN :ids', + new Map{ 'ids' => ids }, + AccessLevel.USER_MODE + ) + ) { + nameById.put(record.Id, String.valueOf(record.get(nameField))); + } + } catch (QueryException viewerCannotReadObject) { + return nameById; + } + return nameById; + } + + private static String nameFieldOf(Schema.SObjectType type) { + for (Schema.SObjectField field : type.getDescribe().fields.getMap().values()) { + Schema.DescribeFieldResult describe = field.getDescribe(); + if (describe.isNameField()) { + return describe.getName(); + } + } + return null; + } +} diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineNames.cls-meta.xml b/force-app/main/default/classes/timeline/AsyncJobTimelineNames.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineNames.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineRequest.cls b/force-app/main/default/classes/timeline/AsyncJobTimelineRequest.cls new file mode 100644 index 0000000..e96d63e --- /dev/null +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineRequest.cls @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/async-lib/blob/main/LICENSE) + * + * v3.1.0 + **/ +public with sharing class AsyncJobTimelineRequest { + @AuraEnabled + public Id recordId { get; set; } + @AuraEnabled + public Integer daysBack { get; set; } + @AuraEnabled + public Integer chainLimit { get; set; } + @AuraEnabled + public Integer chainOffset { get; set; } + @AuraEnabled + public String statusFilter { get; set; } + @AuraEnabled + public List keepChainIds { get; set; } +} diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineRequest.cls-meta.xml b/force-app/main/default/classes/timeline/AsyncJobTimelineRequest.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineRequest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/force-app/main/default/customPermissions/AsyncRequeue.customPermission-meta.xml b/force-app/main/default/customPermissions/AsyncRequeue.customPermission-meta.xml new file mode 100644 index 0000000..9e2fd08 --- /dev/null +++ b/force-app/main/default/customPermissions/AsyncRequeue.customPermission-meta.xml @@ -0,0 +1,6 @@ + + + Lets the user requeue failed async jobs from the record page timeline. Async.requeue() in Apex is not gated by this; only the UI action is. + false + + diff --git a/force-app/main/default/flexipages/AsyncResultRecordPage.flexipage-meta.xml b/force-app/main/default/flexipages/AsyncResultRecordPage.flexipage-meta.xml new file mode 100644 index 0000000..5eefe4f --- /dev/null +++ b/force-app/main/default/flexipages/AsyncResultRecordPage.flexipage-meta.xml @@ -0,0 +1,40 @@ + + + Record page for Async Result: highlights, details, linked records and the other results of the chain. + + + + force:highlightsPanel + force_highlightsPanel + + + header + Region + + + + + force:detailPanel + force_detailPanel + + + main + Region + + + + + force:relatedListContainer + force_relatedListContainer + + + sidebar + Region + + Async Result Record Page + AsyncResult__c + + RecordPage + diff --git a/force-app/main/default/layouts/AsyncResultLink__c-Async Result Link Layout.layout-meta.xml b/force-app/main/default/layouts/AsyncResultLink__c-Async Result Link Layout.layout-meta.xml new file mode 100644 index 0000000..bbf6c6f --- /dev/null +++ b/force-app/main/default/layouts/AsyncResultLink__c-Async Result Link Layout.layout-meta.xml @@ -0,0 +1,60 @@ + + + + false + false + true + + + + Readonly + Name + + + Readonly + Key__c + + + Readonly + Source__c + + + Readonly + ObjectName__c + + + + + Readonly + AsyncResult__c + + + Readonly + UniqueKey__c + + + Readonly + CreatedById + + + Readonly + LastModifiedById + + + + + + false + false + false + + + + + + false + false + false + false + false + diff --git a/force-app/main/default/layouts/AsyncResult__c-Async Result Layout.layout-meta.xml b/force-app/main/default/layouts/AsyncResult__c-Async Result Layout.layout-meta.xml index e070599..ee52356 100644 --- a/force-app/main/default/layouts/AsyncResult__c-Async Result Layout.layout-meta.xml +++ b/force-app/main/default/layouts/AsyncResult__c-Async Result Layout.layout-meta.xml @@ -22,6 +22,14 @@ Readonly Result__c + + Readonly + JobName__c + + + Readonly + Priority__c + @@ -40,6 +48,76 @@ Readonly ChainId__c + + Readonly + ChainName__c + + + Readonly + ChainSequence__c + + + + + + true + true + true + + + + Readonly + StartedAt__c + + + Readonly + FinishedAt__c + + + + + Readonly + NextAttemptAt__c + + + + + + true + true + true + + + + Readonly + ParentCustomJobId__c + + + Readonly + AddedByCustomJobId__c + + + Readonly + Dependencies__c + + + + + Readonly + ChunkRunId__c + + + Readonly + ChunkPage__c + + + Readonly + ChunkPages__c + + + Readonly + DelayMinutes__c + @@ -178,6 +256,14 @@ + + NAME + Key__c + Source__c + ObjectName__c + CREATED_DATE + AsyncResultLink__c.AsyncResult__c + NAME ClassName__c diff --git a/force-app/main/default/lwc/asyncBadgeStyles/asyncBadgeStyles.css b/force-app/main/default/lwc/asyncBadgeStyles/asyncBadgeStyles.css new file mode 100644 index 0000000..d36d6b1 --- /dev/null +++ b/force-app/main/default/lwc/asyncBadgeStyles/asyncBadgeStyles.css @@ -0,0 +1,135 @@ +.chain-badge { + display: inline-flex; + align-items: stretch; + height: 1.25rem; + border-radius: 0.25rem; + overflow: hidden; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + font-variant-numeric: tabular-nums; + white-space: nowrap; + flex-shrink: 0; +} +.chain-badge-main, +.chain-badge-tail { + display: inline-flex; + align-items: center; + gap: 0.4em; + padding: 0 0.5rem; +} +.chain-badge-tail { + padding: 0 0.45rem 0 0.4rem; + background: #fef1ee; + color: var(--bad); +} +.chain-badge-tail-delayed { + background: #eef4ff; + color: var(--run); + border-left: 1px solid rgba(1, 109, 195, 0.3); +} +.chain-badge-delayed .chain-badge-main { + background: #fbf0d9; + color: #8a5a00; +} +.chain-badge-failed .chain-badge-main, +.chain-badge-stopped .chain-badge-main { + background: #fef1ee; + color: var(--bad); +} +.chain-badge-running .chain-badge-main, +.chain-badge-queued .chain-badge-main { + background: #eef4ff; + color: var(--run); +} +.chain-badge-completed .chain-badge-main { + background: #ebf7e6; + color: var(--ok); +} +.chain-badge-text { + line-height: 1; + margin-right: -0.02em; + text-box: trim-both cap alphabetic; +} +@supports not (text-box: trim-both cap alphabetic) { + .chain-badge-text { + position: relative; + top: 0.06em; + } +} +.mark { + position: relative; + display: inline-block; + width: 0.9em; + height: 0.9em; + flex: none; + font-style: normal; +} +.mark-svg { + width: 1.1em; + height: 1.1em; + margin: -0.1em 0; + display: block; +} +.mark-dot::before, +.mark-hollow::before, +.mark-square::before, +.mark-check::before, +.mark-cross::before, +.mark-cross::after { + content: ''; + position: absolute; + box-sizing: border-box; +} +.mark-dot::before { + inset: 0.1em; + border-radius: 50%; + background: currentColor; + animation: pulse-mark 1.2s ease-in-out infinite; +} +@keyframes pulse-mark { + 50% { + opacity: 0.3; + } +} +.mark-hollow::before { + inset: 0.04em; + border-radius: 50%; + border: 0.2em solid currentColor; +} +.mark-square::before { + inset: 0.12em; + border-radius: 0.06em; + background: currentColor; +} +.mark-cross::before, +.mark-cross::after { + left: 50%; + top: 50%; + width: 0.15em; + height: 0.9em; + margin: -0.45em 0 0 -0.075em; + border-radius: 0.08em; + background: currentColor; +} +.mark-cross::before { + transform: rotate(45deg); +} +.mark-cross::after { + transform: rotate(-45deg); +} +.mark-check::before { + left: 0.2em; + top: -0.04em; + width: 0.42em; + height: 0.8em; + border: solid currentColor; + border-width: 0 0.2em 0.2em 0; + transform: rotate(45deg); +} +@media (prefers-reduced-motion: reduce) { + .mark-dot::before { + animation: none; + } +} diff --git a/force-app/main/default/lwc/asyncBadgeStyles/asyncBadgeStyles.js-meta.xml b/force-app/main/default/lwc/asyncBadgeStyles/asyncBadgeStyles.js-meta.xml new file mode 100644 index 0000000..00a544f --- /dev/null +++ b/force-app/main/default/lwc/asyncBadgeStyles/asyncBadgeStyles.js-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + false + diff --git a/force-app/main/default/lwc/asyncChainMap/__tests__/asyncChainMap.test.js b/force-app/main/default/lwc/asyncChainMap/__tests__/asyncChainMap.test.js new file mode 100644 index 0000000..98e062d --- /dev/null +++ b/force-app/main/default/lwc/asyncChainMap/__tests__/asyncChainMap.test.js @@ -0,0 +1,120 @@ +import { createElement } from 'lwc'; +import AsyncChainMap from 'c/asyncChainMap'; +import { CAPS } from 'c/asyncJobTimelineModel'; +import { + failedSequence, + longSequence, + runningSequence, + step, + sequence, + flushPromises +} from '../../asyncJobTimeline/__tests__/data/fixtures'; + +class MapPO { + constructor(el) { + this.el = el; + } + byId(id) { + return this.el.shadowRoot.querySelector(`[data-id="${id}"]`); + } + all(selector) { + return [...this.el.shadowRoot.querySelectorAll(selector)]; + } +} + +const mount = async seq => { + const el = createElement('c-async-chain-map', { is: AsyncChainMap }); + el.sequence = seq; + document.body.appendChild(el); + await flushPromises(); + return new MapPO(el); +}; + +describe('c-async-chain-map', () => { + afterEach(() => { + while (document.body.firstChild) document.body.removeChild(document.body.firstChild); + }); + + it('draws one node per step and one connector per dependency, coloured by outcome', async () => { + const po = await mount(failedSequence()); + expect(po.all('[data-id="node"]')).toHaveLength(5); + expect(po.all('[data-id="node"]')[2].classList.contains('node-bad')).toBe(true); + expect(po.all('[data-id="node"] [data-id="node-name"]')[2].textContent).toBe( + 'Commission split' + ); + expect(po.all('[data-id="arc"]')).toHaveLength(1); + expect(po.all('[data-id="arc"]')[0].classList.contains('arc-unmet')).toBe(true); + expect(po.all('[data-id="arc-label"]')[0].textContent).toContain('not met'); + }); + + it('hangs finalizers under their parent and marks runtime inserts', async () => { + const seq = sequence('c', [ + step({ chainSequence: 1, customJobId: 'c1', jobName: 'Create invoice' }), + step({ chainSequence: 1, customJobId: 'f', parentCustomJobId: 'c1', jobName: 'Audit' }), + step({ + chainSequence: 2, + customJobId: 'x', + jobName: 'Calculate tax', + addedByCustomJobId: 'c1' + }) + ]); + const po = await mount(seq); + expect(po.all('[data-id="node"]')).toHaveLength(2); + expect(po.all('[data-id="fin"]')).toHaveLength(1); + expect(po.all('[data-id="fin"]')[0].textContent).toContain('Audit'); + expect(po.all('[data-id="node"]')[1].classList.contains('node-added')).toBe(true); + expect(po.all('[data-id="node"]')[1].textContent).toContain('added by Create invoice'); + }); + + it('collapses long runs of completed nodes, expands one on click and selects the failed node', async () => { + const po = await mount(longSequence(CAPS.mapBoxes + 20, 30)); + expect(po.all('[data-id="node"]').length).toBeLessThan(CAPS.mapBoxes); + const collapsed = po.all('[data-id="node"].node-collapsed'); + expect(collapsed.length).toBeGreaterThan(0); + expect(collapsed[0].textContent).toContain('completed'); + expect(po.byId('jump-failed')).toBeNull(); + expect(po.all('[data-id="node"].node-selected')[0].classList.contains('node-bad')).toBe( + true + ); + const before = po.all('[data-id="node"]').length; + collapsed[0].dispatchEvent(new CustomEvent('click', { bubbles: true })); + await flushPromises(); + expect(po.all('[data-id="node"]').length).toBeGreaterThan(before); + }); + + it('selects the running node when nothing failed', async () => { + const po = await mount(runningSequence()); + const selected = po.all('[data-id="node"].node-selected'); + expect(selected).toHaveLength(1); + expect(selected[0].classList.contains('node-run')).toBe(true); + }); + + it('shows a page strip inside a chunk node', async () => { + const pages = [1, 2, 3].map(n => + step({ + chainSequence: 1, + customJobId: 'p' + n, + chunkRunId: 'run', + chunkPage: n, + chunkPages: 3, + status: n === 2 ? 'FAILED' : 'COMPLETED' + }) + ); + const po = await mount(sequence('chunky', pages)); + expect(po.all('[data-id="node"]')).toHaveLength(1); + expect(po.all('[data-id="node"] .sq')).toHaveLength(3); + expect(po.all('[data-id="node"]')[0].textContent).toContain('3 pages'); + }); + + it('opens the failed node on arrival and switches the detail panel on click', async () => { + const po = await mount(failedSequence()); + expect(po.byId('detail').textContent).toContain('UNABLE_TO_LOCK_ROW'); + expect(po.byId('detail').textContent).toContain('Commission split'); + po.all('[data-id="node"]')[0].dispatchEvent(new CustomEvent('click', { bubbles: true })); + await flushPromises(); + expect(po.byId('detail').textContent).toContain('Reserve stock'); + po.all('[data-id="node"]')[0].dispatchEvent(new CustomEvent('click', { bubbles: true })); + await flushPromises(); + expect(po.byId('detail')).toBeNull(); + }); +}); diff --git a/force-app/main/default/lwc/asyncChainMap/asyncChainMap.css b/force-app/main/default/lwc/asyncChainMap/asyncChainMap.css new file mode 100644 index 0000000..0dc4c1f --- /dev/null +++ b/force-app/main/default/lwc/asyncChainMap/asyncChainMap.css @@ -0,0 +1,385 @@ +:host { + --ok: #2e844a; + --bad: #ba0517; + --warn: #dd7a01; + --wait: #939393; + --delay: #8a5a00; + --run: #0176d3; + --muted: #747474; + --line: #e5e5e5; + --fin: #9d7fe0; +} +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; +} +.legend { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + font-size: 0.75rem; + color: var(--muted); +} +.lg::before { + content: ''; + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + margin-right: 0.375rem; + vertical-align: -1px; + box-sizing: border-box; +} +.lg-ok::before { + background: var(--ok); +} +.lg-bad::before { + background: var(--bad); +} +.lg-skip::before { + background: var(--warn); +} +.lg-run::before { + background: var(--run); +} +.lg-delay::before { + border: 2px solid var(--delay); + background: #fff; +} +.lg-wait::before { + background: #fff; + border: 2px solid var(--wait); +} +.lg-fin::before { + background: #f1ecfb; + border: 1px solid var(--fin); +} +.lg-dep::before { + width: 14px; + height: 0; + border-top: 2px solid var(--ok); + border-radius: 0; + vertical-align: 2px; +} +.jumps { + display: flex; + gap: 0.5rem; +} +.map-wrap { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 0.25rem; + background: #fff; +} +.canvas { + position: relative; + min-width: max-content; + padding: 28px 24px 16px; +} +.links { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; +} +.arc { + fill: none; + stroke-width: 2; +} +.arc-met { + stroke: var(--ok); +} +.arc-unmet { + stroke: var(--bad); + stroke-dasharray: 5 4; +} +.arc-pending { + stroke: var(--wait); + stroke-dasharray: 2 4; +} +.arrow-met { + fill: var(--ok); +} +.arrow-unmet { + fill: var(--bad); +} +.arrow-pending { + fill: var(--wait); +} +.arc-label { + font-size: 10px; + fill: #444; + paint-order: stroke; + stroke: #fff; + stroke-width: 4px; + stroke-linejoin: round; +} +.columns { + display: flex; + align-items: flex-start; + gap: 1.25rem; +} +.col { + display: flex; + flex-direction: column; + align-items: stretch; + width: 11.5rem; + flex: 0 0 auto; + position: relative; +} +.col + .col::before { + content: ''; + position: absolute; + left: -1.25rem; + width: 1.25rem; + top: 1.25rem; + border-top: 2px solid var(--line); +} +.node { + background: #fff; + border: 1px solid var(--line); + border-left: 4px solid var(--wait); + border-radius: 0.375rem; + padding: 0.5rem 0.625rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + cursor: pointer; + font-size: 0.75rem; + color: var(--muted); + outline: none; +} +.node:hover, +.node:focus-visible { + box-shadow: 0 0 0 2px #d8e6fb; +} +.node-selected { + box-shadow: 0 0 0 2px var(--run); +} +.node-ok { + border-left-color: var(--ok); +} +.node-bad { + border-left-color: var(--bad); + background: #fff8f7; +} +.node-skip { + border-left-color: var(--warn); + background: #fffaf3; +} +.node-run, +.node-retry { + border-left-color: var(--run); +} +.node-delay { + border-left-color: var(--delay); +} +.node-wait { + border-left-color: var(--wait); + border-style: dashed; + border-left-style: solid; +} +.node-added { + border-style: dashed; + border-left-style: solid; +} +.node-collapsed { + background: #f6faf6; + border-style: dotted; + border-left-style: solid; +} +.node-head { + display: flex; + align-items: flex-start; + gap: 0.375rem; +} +.node-name { + font-size: 0.8125rem; + font-weight: 600; + color: #181818; + line-height: 1.25; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; +} +.node-sub { + margin-top: 0.25rem; + font-variant-numeric: tabular-nums; +} +.node-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.25rem; +} +.tag { + font-size: 0.6875rem; + color: #444; + background: #f3f3f3; + border: 1px solid var(--line); + border-radius: 0.25rem; + padding: 0 0.375rem; + line-height: 1.25rem; + white-space: nowrap; +} +.strip { + display: flex; + flex-wrap: wrap; + gap: 2px; + margin-top: 0.375rem; +} +.sq { + width: 6px; + height: 6px; + border-radius: 1px; + background: var(--line); + display: block; +} +.sq-ok { + background: var(--ok); +} +.sq-bad { + background: var(--bad); +} +.sq-skip { + background: var(--warn); +} +.sq-run, +.sq-retry, +.sq-delay { + background: var(--delay); +} +.sq-wait { + background: #cfe2f7; +} +.glyph { + flex: 0 0 auto; + width: 1.125rem; + height: 1.125rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.6875rem; + font-weight: 700; + color: #fff; + box-sizing: border-box; +} +.glyph-sm { + width: 0.875rem; + height: 0.875rem; + font-size: 0.5625rem; +} +.glyph-ok { + background: var(--ok); +} +.glyph-bad { + background: var(--bad); +} +.glyph-skip { + background: var(--warn); +} +.glyph-wait, +.glyph-retry { + background: #fff; + border: 2px solid var(--wait); + color: var(--wait); +} +.glyph-delay { + background: #fff; + border: 2px solid var(--delay); + color: var(--delay); +} +.glyph-run { + background: #fff; + border: 2px solid var(--run); +} +.glyph-run::after { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + border: 2px solid var(--run); + border-right-color: transparent; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.fin { + margin: 0.375rem 0 0 1rem; + padding: 0.25rem 0.5rem; + border: 1px solid var(--fin); + background: #f1ecfb; + border-radius: 0.25rem; + font-size: 0.6875rem; + color: #3b2a6b; + display: flex; + align-items: center; + gap: 0.375rem; + position: relative; +} +.fin::before { + content: ''; + position: absolute; + left: -0.625rem; + top: -0.375rem; + width: 0.5rem; + height: 0.9rem; + border-left: 2px solid var(--fin); + border-bottom: 2px solid var(--fin); + border-bottom-left-radius: 4px; +} +.fin-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.caption { + margin: 0.5rem 0 0; + font-size: 0.75rem; + color: var(--muted); +} +.detail { + margin-top: 0.75rem; + border: 1px solid var(--line); + border-radius: 0.25rem; + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + background: #fafafa; +} +.detail-head { + display: flex; + justify-content: space-between; + align-items: center; +} +.detail h3 { + margin: 0 0 0.25rem; + font-size: 0.875rem; + font-weight: 700; +} +.detail-line { + display: grid; + grid-template-columns: 7rem 1fr; + gap: 0.5rem; + padding: 0.125rem 0; +} +.detail-label { + color: var(--muted); +} +.detail-value { + overflow-wrap: anywhere; +} +@media (prefers-reduced-motion: reduce) { + .glyph-run::after { + animation: none; + } +} diff --git a/force-app/main/default/lwc/asyncChainMap/asyncChainMap.html b/force-app/main/default/lwc/asyncChainMap/asyncChainMap.html new file mode 100644 index 0000000..5b72c99 --- /dev/null +++ b/force-app/main/default/lwc/asyncChainMap/asyncChainMap.html @@ -0,0 +1,158 @@ + diff --git a/force-app/main/default/lwc/asyncChainMap/asyncChainMap.js b/force-app/main/default/lwc/asyncChainMap/asyncChainMap.js new file mode 100644 index 0000000..a5ae6f4 --- /dev/null +++ b/force-app/main/default/lwc/asyncChainMap/asyncChainMap.js @@ -0,0 +1,340 @@ +import USER_LOCALE from '@salesforce/i18n/locale'; +import { api } from 'lwc'; +import LightningModal from 'lightning/modal'; +import { + CAPS, + currentRowKey, + groupSteps, + formatDuration, + pageStripFor, + delayOf, + clockOf, + statusOf +} from 'c/asyncJobTimelineModel'; + +const GLYPHS = { ok: '✓', bad: '✕', skip: '→', wait: '○', delay: '◷', run: '', retry: '↻' }; +const OUTCOME_WORDS = { SUCCESS: 'succeeded', FAILURE: 'failed', COMPLETED: 'finished' }; +const LANE_HEIGHT = 18; +const LANE_TOP = 28; + +export default class AsyncChainMap extends LightningModal { + @api sequence; + + selectedKey = null; + focused = false; + expandedRuns = new Set(); + connectors = []; + connectorSignature = ''; + + renderedCallback() { + this.measureConnectors(); + this.focusFirstInterestingNode(); + } + + get rows() { + return groupSteps(this.sequence?.steps || []); + } + + get nameByCustomJobId() { + const names = {}; + (this.sequence?.steps || []).forEach(step => { + names[step.customJobId] = step.jobName || step.className; + }); + return names; + } + + get groups() { + const top = this.rows.filter(row => row.depth === 0 && row.kind !== 'finalizer'); + const groups = []; + let run = []; + const flushRun = () => { + const runKey = run.length ? 'run-' + run[0].key : null; + const collapse = + run.length > CAPS.mapCollapseRun && + top.length > CAPS.mapBoxes && + !this.expandedRuns.has(runKey); + if (collapse) groups.push({ key: runKey, rows: run, collapsed: true }); + else run.forEach(row => groups.push({ key: row.key, rows: [row], collapsed: false })); + run = []; + }; + top.forEach(row => { + if (row.status.key === 'ok') run.push(row); + else { + flushRun(); + groups.push({ key: row.key, rows: [row], collapsed: false }); + } + }); + flushRun(); + return groups; + } + + get columns() { + const finalizers = this.rows.filter(row => row.kind === 'finalizer' && row.depth === 1); + return this.groups.map(group => ({ + key: group.key, + node: group.collapsed ? this.collapsedNodeOf(group) : this.nodeOf(group.rows[0]), + finalizers: group.collapsed + ? [] + : finalizers + .filter(row => row.step.parentCustomJobId === group.rows[0].step.customJobId) + .map(row => this.finalizerOf(row)) + })); + } + + get arcs() { + const keyByCustomJobId = new Map(); + this.groups.forEach(group => + group.rows.forEach(row => keyByCustomJobId.set(row.step.customJobId, group.key)) + ); + const arcs = []; + this.rows + .filter(row => row.depth === 0 && row.kind !== 'finalizer') + .forEach(row => { + this.dependenciesOf(row.step).forEach(dependency => { + const from = keyByCustomJobId.get(dependency.resolvedTargetCustomJobId); + const to = keyByCustomJobId.get(row.step.customJobId); + if (!from || !to || from === to) return; + const unmet = row.step.status === 'SKIPPED_DEPENDENCY'; + const pending = ['wait', 'delay', 'run', 'retry'].includes( + statusOf(row.step).key + ); + const state = unmet ? 'unmet' : pending ? 'pending' : 'met'; + arcs.push({ + key: `${row.key}-${dependency.resolvedTargetCustomJobId}`, + from, + to, + state, + label: `${OUTCOME_WORDS[dependency.requiredOutcome] || 'finished'} · ${ + { unmet: 'not met', pending: 'pending', met: 'met' }[state] + }` + }); + }); + }); + return arcs; + } + + get canvasStyle() { + return `padding-top:${LANE_TOP + this.arcs.length * LANE_HEIGHT}px`; + } + + get selectedRow() { + return this.rows.find(row => row.key === this.selectedKey) || null; + } + + get detail() { + const row = this.selectedRow; + if (!row) return null; + const step = row.step; + const lines = [ + ['Job', step.jobName || step.className], + ['Class', step.className], + ['Status', row.status.label], + ['Position', step.chainSequence ? '#' + step.chainSequence : null], + ['Started', this.formatted(step.startedAt)], + ['Finished', this.formatted(step.finishedAt)], + ['Attempts', step.retryAttempts ? step.retryAttempts + 1 : null], + ['Exception', step.exceptionType], + ['Message', step.exceptionMessage], + ['Skip reason', step.skipReason], + ['Requeue', step.requeueStatus], + ['Records', (step.records || []).map(record => record.name).join(', ')] + ].filter(([, value]) => value); + return { + title: step.jobName || step.className, + lines: lines.map(([label, value]) => ({ label, value })) + }; + } + + collapsedNodeOf(group) { + const first = group.rows[0].step.chainSequence; + const last = group.rows[group.rows.length - 1].step.chainSequence; + return { + key: group.key, + className: 'node node-ok node-collapsed', + glyphClass: 'glyph glyph-ok', + glyphText: GLYPHS.ok, + name: `${group.rows.length} completed`, + title: 'Click to expand', + sub: `steps ${first} to ${last} · click to expand`, + tags: [], + strip: null + }; + } + + nodeOf(row) { + const step = row.step; + const addedAtRuntime = Boolean(step.addedByCustomJobId); + const name = step.jobName || step.className; + return { + key: row.key, + className: + `node node-${row.status.key}` + + (addedAtRuntime ? ' node-added' : '') + + (row.key === this.selectedKey ? ' node-selected' : ''), + glyphClass: `glyph glyph-${row.status.key}`, + glyphText: GLYPHS[row.status.key], + name, + title: step.jobName ? step.className : name, + sub: this.subOf(row), + tags: this.tagsOf(row), + strip: row.kind === 'chunk' ? this.stripOf(row) : null + }; + } + + subOf(row) { + const parts = []; + if (row.step.chainSequence) parts.push('#' + row.step.chainSequence); + if (row.kind === 'chunk') { + const total = row.step.chunkPages || row.pages.length; + parts.push(`${total} page${total === 1 ? '' : 's'}`); + } + if (row.status.key === 'ok') { + const duration = formatDuration( + Date.parse(row.step.finishedAt) - Date.parse(row.step.startedAt) + ); + parts.push(duration || 'completed'); + } else if (delayOf(row).until) { + parts.push(`delayed · until ${clockOf(delayOf(row).until)}`); + } else if (delayOf(row).plannedMinutes) { + parts.push(`enqueued · delay ${delayOf(row).plannedMinutes} min`); + } else if (row.step.retryAttempts) { + parts.push(`${row.status.label.toLowerCase()} · attempt ${row.step.retryAttempts + 1}`); + } else { + parts.push(row.status.label.toLowerCase()); + } + return parts.join(' · '); + } + + tagsOf(row) { + const tags = []; + const step = row.step; + if (step.priority !== null && step.priority !== undefined) tags.push('P' + step.priority); + if (step.addedByCustomJobId) + tags.push('↳ added by ' + (this.nameByCustomJobId[step.addedByCustomJobId] || '?')); + return tags; + } + + stripOf(row) { + const strip = pageStripFor(row); + return { + summary: strip.summary, + squares: (strip.squares || []).map(square => ({ + page: square.page, + className: `sq sq-${square.key}` + })) + }; + } + + finalizerOf(row) { + return { + key: row.key, + className: `fin fin-${row.status.key}`, + glyphClass: `glyph glyph-sm glyph-${row.status.key}`, + glyphText: GLYPHS[row.status.key], + name: row.step.jobName || row.step.className, + title: `finalizer · ${row.status.label}` + }; + } + + focusFirstInterestingNode() { + if (this.focused) return; + this.focused = true; + const currentKey = currentRowKey(this.rows); + const node = this.firstNodeMatching( + candidate => + candidate.classList.contains('node-bad') || candidate.dataset.key === currentKey + ); + if (!node) return; + if (typeof node.scrollIntoView === 'function') + node.scrollIntoView({ inline: 'center', block: 'nearest' }); + this.selectedKey = node.dataset.key; + } + + firstNodeMatching(matches) { + return [...(this.refs.canvas?.querySelectorAll('[data-id="node"]') || [])].find(matches); + } + + // Node positions only exist after render, so the connectors are drawn one pass later and + // re-set only when they moved, which keeps renderedCallback from re-entering forever. + measureConnectors() { + const canvas = this.refs.canvas; + if (!canvas) return; + const origin = canvas.getBoundingClientRect(); + const nodes = new Map(); + canvas.querySelectorAll('[data-id="node"]').forEach(node => { + const rect = node.getBoundingClientRect(); + nodes.set(node.dataset.key, { + x: rect.left - origin.left + rect.width / 2, + top: rect.top - origin.top + }); + }); + const connectors = this.arcs.map((arc, index) => { + const from = nodes.get(arc.from) || { x: 0, top: 0 }; + const to = nodes.get(arc.to) || { x: 0, top: 0 }; + const laneY = LANE_TOP + (this.arcs.length - 1 - index) * LANE_HEIGHT - 8; + const step = 6; + const fromX = from.x + (from.x < to.x ? -step : step); + const toX = to.x + (from.x < to.x ? step : -step); + return { + key: arc.key, + d: `M ${fromX} ${from.top} V ${laneY} H ${toX} V ${to.top - 7}`, + head: `M ${toX - 4} ${to.top - 8} L ${toX + 4} ${to.top - 8} L ${toX} ${to.top - 1} z`, + className: `arc arc-${arc.state}`, + headClass: `arrow arrow-${arc.state}`, + labelX: (fromX + toX) / 2, + labelY: laneY - 3, + label: arc.label + }; + }); + const signature = JSON.stringify(connectors); + if (signature === this.connectorSignature) return; + this.connectorSignature = signature; + this.connectors = connectors; + } + + dependenciesOf(step) { + if (!step.dependencies) return []; + try { + const parsed = JSON.parse(step.dependencies); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + formatted(iso) { + const at = Date.parse(iso); + if (!iso || Number.isNaN(at)) return null; + return new Date(at).toLocaleString(USER_LOCALE, { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } + + selectNode(event) { + const key = event.currentTarget.dataset.key; + if (key.startsWith('run-')) { + this.expandedRuns = new Set([...this.expandedRuns, key]); + return; + } + this.selectedKey = key === this.selectedKey ? null : key; + } + + keySelectNode(event) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.selectNode(event); + } + } + + clearSelection() { + this.selectedKey = null; + } + + handleClose() { + this.close(); + } +} diff --git a/force-app/main/default/lwc/asyncChainMap/asyncChainMap.js-meta.xml b/force-app/main/default/lwc/asyncChainMap/asyncChainMap.js-meta.xml new file mode 100644 index 0000000..00a544f --- /dev/null +++ b/force-app/main/default/lwc/asyncChainMap/asyncChainMap.js-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + false + diff --git a/force-app/main/default/lwc/asyncJobSequence/__tests__/asyncJobSequence.test.js b/force-app/main/default/lwc/asyncJobSequence/__tests__/asyncJobSequence.test.js new file mode 100644 index 0000000..ecc6be2 --- /dev/null +++ b/force-app/main/default/lwc/asyncJobSequence/__tests__/asyncJobSequence.test.js @@ -0,0 +1,459 @@ +import { createElement } from 'lwc'; +import AsyncJobSequence from 'c/asyncJobSequence'; +import { CAPS } from 'c/asyncJobTimelineModel'; +import { + step, + sequence, + failedSequence, + runningSequence, + completedSequence, + longSequence, + flushPromises +} from '../../asyncJobTimeline/__tests__/data/fixtures'; + +class SequencePO { + constructor(el) { + this.el = el; + } + byId(id) { + return this.el.shadowRoot.querySelector(`[data-id="${id}"]`); + } + get steps() { + return [...this.el.shadowRoot.querySelectorAll('c-async-job-step')]; + } + get gaps() { + return [...this.el.shadowRoot.querySelectorAll('[data-id="gap"]')]; + } +} + +const mount = async (chain, props = {}) => { + const el = createElement('c-async-job-sequence', { is: AsyncJobSequence }); + el.sequence = chain; + el.canRequeue = true; + el.defaultExpanded = true; + Object.assign(el, props); + document.body.appendChild(el); + await flushPromises(); + return new SequencePO(el); +}; + +describe('c-async-job-sequence', () => { + afterEach(() => { + while (document.body.firstChild) document.body.removeChild(document.body.firstChild); + }); + + it('expands a failed sequence with badge, progress and a requeue button counting replayable rows', async () => { + const po = await mount(failedSequence()); + expect(po.byId('badge').textContent).toBe('Failed'); + expect(po.byId('progress-text').textContent).toContain('2 of 5'); + expect(po.steps).toHaveLength(5); + expect(po.byId('requeue-failed').label).toContain('2'); + }); + + it('matches the words to a count of one', async () => { + const po = await mount( + sequence('one', [ + step({ + chainSequence: 1, + customJobId: 'a', + status: 'FAILED', + requeueStatus: 'Stored' + }) + ]) + ); + expect(po.byId('requeue-failed').label).toBe('Requeue failed step (1)'); + expect(po.byId('progress-text').textContent).toContain('0 of 1 step completed'); + }); + + it('says one hidden step in the singular', async () => { + const steps = Array.from({ length: CAPS.stepsCollapsed + 1 }, (_, index) => + step({ + chainSequence: index + 1, + customJobId: `s${index}`, + status: index === 3 ? 'COMPLETED' : 'FAILED' + }) + ); + const po = await mount(sequence('gap', steps)); + expect(po.gaps[0].textContent.trim()).toBe('1 completed step hidden · Show'); + }); + + describe('compact layout controls', () => { + it('offers requeue as a short count with the full text on hover', async () => { + const po = await mount(failedSequence()); + const compact = po.byId('requeue-failed-compact'); + expect(compact.label).toBe('2'); + expect(compact.iconName).toBe('utility:refresh'); + expect(compact.title).toBe(po.byId('requeue-failed').label); + }); + + it('starts the same requeue from the compact button', async () => { + const po = await mount(failedSequence()); + const handler = jest.fn(); + po.el.addEventListener('requeue', handler); + po.byId('requeue-failed-compact').click(); + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + describe('collapsed progress', () => { + const jobs = count => + Array.from({ length: count }, (_, index) => + step({ chainSequence: index + 1, customJobId: `j${index}` }) + ); + const chunk = (id, at) => + step({ + chainSequence: at, + customJobId: id, + chunkRunId: id, + chunkPage: 1, + chunkPages: 1 + }); + const collapsed = chain => mount(chain, { defaultExpanded: false }); + + it('shows every job as a dot while they all fit', async () => { + const po = await collapsed(sequence('fit', jobs(CAPS.dots))); + expect(po.el.shadowRoot.querySelectorAll('[data-id="dots"] i')).toHaveLength(CAPS.dots); + expect(po.byId('mini-progress')).toBeNull(); + }); + + it('switches to a bar when the jobs do not fit, with no partial dots', async () => { + const po = await collapsed(sequence('over', jobs(CAPS.dots + 1))); + expect(po.byId('dots')).toBeNull(); + expect(po.byId('mini-progress').textContent).toContain('100 %'); + expect( + po.el.shadowRoot.querySelectorAll('[data-id="mini-progress"] .seg-ok') + ).toHaveLength(1); + }); + + it('counts a chunk strip as three dots when deciding what fits', async () => { + const fits = [...jobs(CAPS.dots - CAPS.dotsPerChunk), chunk('c', 99)]; + expect((await collapsed(sequence('fits', fits))).byId('dots')).not.toBeNull(); + const over = [...jobs(CAPS.dots - CAPS.dotsPerChunk + 1), chunk('c', 99)]; + expect((await collapsed(sequence('over', over))).byId('dots')).toBeNull(); + }); + + it('adds up several chunks and jobs', async () => { + const over = [ + ...jobs(CAPS.dots - 2 * CAPS.dotsPerChunk + 1), + chunk('c1', 90), + chunk('c2', 91) + ]; + expect((await collapsed(sequence('over', over))).byId('mini-progress')).not.toBeNull(); + }); + + it('gives a chain of only chunk runs the bar', async () => { + const po = await collapsed(sequence('pages', [chunk('c', 1)])); + expect(po.byId('dots')).toBeNull(); + expect(po.byId('mini-progress')).not.toBeNull(); + }); + }); + + describe('rail and tag targets', () => { + const dependent = () => + sequence('deps', [ + step({ chainSequence: 1, customJobId: 'first', status: 'FAILED' }), + step({ chainSequence: 2, customJobId: 'second', status: 'FAILED' }), + step({ + chainSequence: 3, + customJobId: 'third', + status: 'FAILED', + dependencies: JSON.stringify([ + { resolvedTargetCustomJobId: 'first', requiredOutcome: 'SUCCESS' } + ]) + }) + ]); + + it('tells only the last step that the rail ends there', async () => { + const po = await mount(dependent()); + expect(po.steps.map(item => item.isLastRow)).toEqual([false, false, true]); + }); + + it('marks the job a tag points at and clears it when the tag lets go', async () => { + const po = await mount(dependent()); + po.steps[2].dispatchEvent(new CustomEvent('tagtarget', { detail: { jobId: 'first' } })); + await flushPromises(); + expect(po.steps.map(item => item.isTarget)).toEqual([true, false, false]); + po.steps[2].dispatchEvent(new CustomEvent('tagtarget', { detail: { jobId: null } })); + await flushPromises(); + expect(po.steps.map(item => item.isTarget)).toEqual([false, false, false]); + }); + }); + + it('stays collapsed unless told otherwise, even when failed', async () => { + const po = await mount(failedSequence(), { defaultExpanded: false }); + expect(po.steps).toHaveLength(0); + po.el.expand(); + await flushPromises(); + expect(po.steps).toHaveLength(5); + }); + + it('collapses a completed sequence into one line with a dot per step', async () => { + const po = await mount(completedSequence(), { defaultExpanded: false }); + expect(po.steps).toHaveLength(0); + expect(po.byId('badge').textContent).toBe('Completed'); + expect(po.el.shadowRoot.querySelectorAll('[data-id="dots"] i')).toHaveLength(3); + expect(po.byId('started').title).toContain('2026'); + po.byId('toggle').click(); + await flushPromises(); + expect(po.steps).toHaveLength(3); + }); + + it('toggles from the header row but not from its action buttons', async () => { + const po = await mount(completedSequence(), { defaultExpanded: false }); + po.byId('head').click(); + await flushPromises(); + expect(po.steps).toHaveLength(3); + po.byId('actions').click(); + await flushPromises(); + expect(po.steps).toHaveLength(3); + po.byId('head').click(); + await flushPromises(); + expect(po.steps).toHaveLength(0); + }); + + it('hides expanded gaps again after a collapse', async () => { + const po = await mount(longSequence(30, 17)); + po.gaps[0].click(); + await flushPromises(); + expect(po.steps).toHaveLength(19); + po.byId('toggle').click(); + await flushPromises(); + po.byId('toggle').click(); + await flushPromises(); + expect(po.steps).toHaveLength(6); + expect(po.gaps).toHaveLength(2); + }); + + it('shows running state with elapsed time and no requeue button', async () => { + const po = await mount(runningSequence()); + expect(po.byId('badge').textContent).toBe('Running'); + expect(po.byId('subline').textContent).toMatch(/running \d/); + expect(po.byId('requeue-failed')).toBeNull(); + expect(po.steps[0].isCurrent).toBe(false); + expect(po.steps[1].isCurrent).toBe(true); + expect(po.steps[2].isCurrent).toBe(false); + }); + + it('says queued for while nothing has started and marks the step the platform holds', async () => { + const queued = runningSequence(); + queued.steps[0].status = 'ENQUEUED'; + queued.steps[1].apexJobStatus = 'Queued'; + const po = await mount(queued); + expect(po.byId('badge').textContent).toBe('Queued'); + expect(po.byId('badge').querySelector('.mark-hollow')).not.toBeNull(); + expect(po.byId('subline').textContent).toMatch(/queued for \d/); + expect(po.steps[1].isCurrent).toBe(true); + expect(po.steps[2].isCurrent).toBe(false); + }); + + it('shows a chain that has not started as delayed, with its start time', async () => { + const seq = runningSequence(); + seq.steps.forEach(item => { + item.status = 'ENQUEUED'; + item.apexJobStatus = null; + item.startedAt = null; + item.finishedAt = null; + }); + seq.steps[0].apexJobStatus = 'Queued'; + seq.steps[0].nextAttemptAt = new Date(Date.now() + 300000).toISOString(); + const po = await mount(seq); + expect(po.byId('badge').textContent).toBe('Delayed'); + expect(po.byId('badge-delayed')).toBeNull(); + expect(po.byId('badge').classList.contains('chain-badge-delayed')).toBe(true); + expect(po.byId('subline').textContent).toContain('starts at'); + expect(po.byId('subline').textContent).not.toContain('queued for'); + }); + + it('adds Delayed to a running chain and says when the next step goes', async () => { + const seq = runningSequence(); + const next = seq.steps[seq.steps.length - 1]; + seq.steps.forEach(item => { + item.status = 'COMPLETED'; + item.apexJobStatus = null; + }); + Object.assign(next, { + status: 'ENQUEUED', + apexJobStatus: 'Queued', + finishedAt: null, + nextAttemptAt: new Date(Date.now() + 300000).toISOString() + }); + const po = await mount(seq); + expect(po.byId('badge').textContent).toContain('Running'); + expect(po.byId('badge-delayed').textContent).toBe('Delayed'); + expect(po.byId('subline').textContent).toContain('next step at'); + expect(po.byId('subline').textContent).not.toContain('running'); + }); + + it('keeps the failure count next to Delayed on a running chain', async () => { + const seq = runningSequence(); + const next = seq.steps[seq.steps.length - 1]; + seq.steps.forEach(item => { + item.status = 'COMPLETED'; + item.apexJobStatus = null; + }); + seq.steps[0].status = 'FAILED'; + Object.assign(next, { + status: 'ENQUEUED', + apexJobStatus: 'Queued', + finishedAt: null, + nextAttemptAt: new Date(Date.now() + 300000).toISOString() + }); + const po = await mount(seq); + expect(po.byId('badge').textContent).toContain('Running'); + expect(po.byId('badge-failed').textContent).toBe('1'); + expect(po.byId('badge-delayed').textContent).toBe('Delayed'); + }); + + it('stays running between steps with a hollow mark once a step has finished', async () => { + const between = runningSequence(); + between.steps[1].apexJobStatus = 'Queued'; + const po = await mount(between); + expect(po.byId('badge').textContent).toBe('Running'); + expect(po.byId('badge').querySelector('.mark-hollow')).not.toBeNull(); + expect(po.byId('badge').title).toBe('Running · waiting for the platform'); + expect(po.byId('badge-failed')).toBeNull(); + }); + + it('pulses a dot while a step executes and counts failures so far in a red tail', async () => { + const running = runningSequence(); + running.steps[0].status = 'FAILED'; + const po = await mount(running); + expect(po.byId('badge').querySelector('.mark-dot')).not.toBeNull(); + expect(po.byId('badge-failed').textContent).toBe('1'); + expect(po.byId('badge').title).toBe('Running · a step is executing · 1 failed so far'); + }); + + it('shows the refresh mark while a retry waits', async () => { + const retrying = runningSequence(); + retrying.steps[1].apexJobStatus = undefined; + retrying.steps[1].retryAttempts = 1; + retrying.steps[1].nextAttemptAt = new Date(Date.now() + 60000).toISOString(); + const po = await mount(retrying); + expect(po.byId('badge').querySelector('svg.mark-svg')).not.toBeNull(); + expect(po.byId('badge').title).toBe('Running · waiting for a retry'); + }); + + it('hands the page record id to every step', async () => { + const po = await mount(completedSequence(), { pageRecordId: '001000000000001AAA' }); + expect(po.steps.every(row => row.pageRecordId === '001000000000001AAA')).toBe(true); + }); + + it('marks no step current once the chain settled', async () => { + const po = await mount(completedSequence()); + expect(po.steps.some(row => row.isCurrent)).toBe(false); + }); + + it('reports how far its header moved once the toggle rendered', async () => { + const po = await mount(completedSequence(), { defaultExpanded: false }); + const handler = jest.fn(); + po.el.addEventListener('sequencetoggle', handler); + po.el.getBoundingClientRect = jest + .fn() + .mockReturnValueOnce({ top: 300 }) + .mockReturnValueOnce({ top: 180 }); + po.byId('head').click(); + await flushPromises(); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].detail).toEqual({ open: true, delta: -120 }); + expect(po.steps).toHaveLength(3); + }); + + it('hides requeue while the chain is still in flight', async () => { + const live = failedSequence(); + live.steps[4].status = 'ENQUEUED'; + live.steps[4].apexJobStatus = 'Queued'; + const po = await mount(live); + expect(po.byId('requeue-failed')).toBeNull(); + expect(po.steps[2].chainInFlight).toBe(true); + }); + + it('hides the requeue button without permission', async () => { + const po = await mount(failedSequence(), { canRequeue: false }); + expect(po.byId('requeue-failed')).toBeNull(); + }); + + it('fires one requeue event with every replayable id and a label, from a button group', async () => { + const po = await mount(failedSequence()); + const handler = jest.fn(); + po.el.addEventListener('requeue', handler); + expect(po.el.shadowRoot.querySelector('lightning-button-group')).not.toBeNull(); + expect(po.byId('requeue-failed').variant).toBe('brand-outline'); + po.byId('requeue-failed').click(); + await flushPromises(); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].detail.resultIds).toHaveLength(2); + expect(handler.mock.calls[0][0].detail.label).toContain('2 failed steps'); + }); + + it('folds long completed runs into gaps that expand on click', async () => { + const po = await mount(longSequence(30, 17)); + expect(po.steps).toHaveLength(6); + expect(po.gaps).toHaveLength(2); + expect(po.gaps[0].textContent).toContain('13 completed steps'); + po.gaps[0].click(); + await flushPromises(); + expect(po.steps).toHaveLength(19); + }); + + it('caps a very long sequence and points to the results list', async () => { + const po = await mount(longSequence(CAPS.stepsMax + 50, 5)); + po.gaps.forEach(gap => gap.click()); + await flushPromises(); + expect(po.steps.length).toBeLessThanOrEqual(CAPS.stepsMax); + expect(po.byId('cap-note').textContent).toContain( + `${CAPS.stepsMax} of ${CAPS.stepsMax + 50}` + ); + }); + + it('passes highlight flags down to the matching steps', async () => { + const po = await mount(runningSequence(), { + highlight: { added: ['r3'], changed: ['r2'] } + }); + expect(po.steps[2].isNew).toBe(true); + expect(po.steps[1].isChanged).toBe(true); + expect(po.steps[0].isNew).toBe(false); + }); + + it('offers the chain map and the first result from the menu', async () => { + const seq = failedSequence(); + const po = await mount(seq); + const opened = jest.fn(); + const result = jest.fn(); + po.el.addEventListener('openmap', opened); + po.el.addEventListener('openresult', result); + po.byId('menu').dispatchEvent(new CustomEvent('select', { detail: { value: 'map' } })); + po.byId('menu').dispatchEvent(new CustomEvent('select', { detail: { value: 'result' } })); + expect(opened.mock.calls[0][0].detail.chainId).toBe('chain-failed'); + expect(result.mock.calls[0][0].detail.resultId).toBe(seq.steps[0].id); + }); + + it('forwards a replay link from a step', async () => { + const seq = failedSequence(); + seq.steps[2].requeueStatus = 'Requeued'; + seq.steps[2].replayResultId = 'a0X000000000002AAA'; + seq.steps[2].replayChainId = 'chain-replay'; + const po = await mount(seq); + const handler = jest.fn(); + po.el.addEventListener('openreplay', handler); + po.steps[2].dispatchEvent( + new CustomEvent('openreplay', { + detail: { resultId: 'a0X000000000002AAA', chainId: 'chain-replay' } + }) + ); + expect(handler.mock.calls[0][0].detail.chainId).toBe('chain-replay'); + }); + + it('renders the menu alone when there is nothing to requeue', async () => { + const po = await mount(completedSequence()); + expect(po.el.shadowRoot.querySelector('lightning-button-group')).toBeNull(); + expect(po.byId('menu')).not.toBeNull(); + }); + + it('labels a replay with the result it was requeued from', async () => { + const replay = runningSequence(); + replay.steps[0].requeuedFrom = 'a00000000000001AAA'; + replay.steps[0].requeuedFromName = 'AR-00231'; + const po = await mount(replay); + expect(po.byId('replay-of').textContent).toContain('AR-00231'); + }); +}); diff --git a/force-app/main/default/lwc/asyncJobSequence/asyncJobSequence.css b/force-app/main/default/lwc/asyncJobSequence/asyncJobSequence.css new file mode 100644 index 0000000..88beec6 --- /dev/null +++ b/force-app/main/default/lwc/asyncJobSequence/asyncJobSequence.css @@ -0,0 +1,272 @@ +@import 'c/asyncBadgeStyles'; +:host { + --ok: #2e844a; + --bad: #ba0517; + --warn: #dd7a01; + --wait: #939393; + --run: #0176d3; + --muted: #747474; + --line: #e5e5e5; + display: block; +} +.chain { + border-bottom: 1px solid var(--line); +} +:host { + container-type: inline-size; +} +.chain-head { + display: grid; + grid-template-columns: 2rem 1fr auto; + gap: 0.25rem 0.5rem; + align-items: center; + padding: 0.625rem 1rem 0.625rem 0.5rem; + cursor: pointer; +} +.chain-block { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} +.chain-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.25rem 0.75rem; +} +.chain-head:hover { + background: #fafafa; +} +.chain-body { + --muted: #666; + --rail: #c9d4e0; + background: #f3f6f9; + border-top: 1px solid var(--line); +} +.toggle { + justify-self: center; +} +.gap:focus-visible { + outline: 2px solid var(--run); + outline-offset: 1px; +} +.chain-title { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0.5rem; + min-width: 0; +} +.chain-title h3 { + font-size: 0.9375rem; + font-weight: 700; + margin: 0; +} +.chain-sub { + color: var(--muted); + font-size: 0.75rem; +} +.chain-actions { + display: flex; + justify-content: flex-end; + align-items: center; + cursor: default; +} +.badge { + display: inline-block; + padding: 0.0625rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} +.badge-new { + background: #fde7a0; + color: #5c3b00; + animation: pulse-new 1.6s ease-in-out infinite; +} +@keyframes pulse-new { + 50% { + box-shadow: 0 0 0 4px rgba(221, 122, 1, 0.25); + } +} +@media (prefers-reduced-motion: reduce) { + .badge-new { + animation: none; + } +} +.badge-replay { + background: #f3f3f3; + color: var(--muted); + text-transform: none; + font-weight: 500; +} +.dots { + display: inline-flex; + gap: 3px; + vertical-align: middle; + flex-wrap: nowrap; +} +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: block; + background: #fff; + border: 1.5px solid var(--wait); + box-sizing: border-box; +} +.dot-ok { + background: var(--ok); + border-color: var(--ok); +} +.dot-bad { + background: var(--bad); + border-color: var(--bad); +} +.dot-skip { + background: var(--warn); + border-color: var(--warn); +} +.dot-strip { + width: 30px; + border-radius: 4px; + border-color: var(--line); +} +.mini-progress { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--muted); + font-size: 0.75rem; +} +.mini-bar { + width: 11rem; +} +.dot-delay { + background: #fff; + border-color: #8a5a00; +} +.dot-run, +.dot-retry { + background: var(--run); + border-color: var(--run); +} +.progress { + position: relative; + padding: 0.5rem 1rem 0.5rem 3rem; + display: grid; + grid-template-columns: 1fr auto; + gap: 0.25rem 0.75rem; + align-items: center; + font-size: 0.75rem; + color: var(--muted); +} +.progress::before, +.gap-row::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: var(--rail); +} +.progress::before { + left: calc(1.5rem - 1px); +} +.gap-row { + position: relative; + padding-left: 2.5rem; +} +.gap-row::before { + left: calc(1rem - 1px); +} +.bar { + grid-column: 1 / -1; + height: 6px; + border-radius: 3px; + background: var(--line); + overflow: hidden; + display: flex; +} +.seg { + display: block; + height: 100%; + flex: 0 1 auto; + transition: width 0.4s ease; +} +.seg-ok { + background: var(--ok); +} +.seg-bad { + background: var(--bad); + min-width: 4px; + flex-shrink: 0; +} +.seg-skip { + background: var(--warn); + min-width: 4px; + flex-shrink: 0; +} +.seg-run { + background: repeating-linear-gradient(90deg, var(--run) 0 8px, #7fb6ea 8px 16px); + background-size: 32px 100%; + animation: slide 1s linear infinite; +} +@keyframes slide { + to { + background-position: 32px 0; + } +} +@media (prefers-reduced-motion: reduce) { + .seg-run { + animation: none; + } + .seg { + transition: none; + } +} +.steps { + list-style: none; + margin: 0; + padding: 0 1rem 0.75rem 0.5rem; +} +.gap { + width: 100%; + text-align: left; + background: #fff; + border: 1px dashed #c9d4e0; + border-radius: 0.25rem; + color: var(--run); + font-size: 0.75rem; + padding: 0.25rem 0.75rem; + margin: 0.25rem 0; + cursor: pointer; +} +.cap-note { + font-size: 0.75rem; + color: var(--muted); + padding: 0 1rem 0.75rem 3rem; +} +@container (min-width: 641px) { + .compact-only { + display: none; + } +} +@container (max-width: 640px) { + .full-only { + display: none; + } + .chain-head { + padding-right: 0.75rem; + } + .chain-actions, + .toggle { + align-self: start; + } + .mini-bar { + width: 6rem; + } +} diff --git a/force-app/main/default/lwc/asyncJobSequence/asyncJobSequence.html b/force-app/main/default/lwc/asyncJobSequence/asyncJobSequence.html new file mode 100644 index 0000000..be4dd96 --- /dev/null +++ b/force-app/main/default/lwc/asyncJobSequence/asyncJobSequence.html @@ -0,0 +1,277 @@ + diff --git a/force-app/main/default/lwc/asyncJobSequence/asyncJobSequence.js b/force-app/main/default/lwc/asyncJobSequence/asyncJobSequence.js new file mode 100644 index 0000000..8a04690 --- /dev/null +++ b/force-app/main/default/lwc/asyncJobSequence/asyncJobSequence.js @@ -0,0 +1,400 @@ +import { LightningElement, api } from 'lwc'; +import { + CAPS, + groupSteps, + sequenceSummary, + collapseCompletedRuns, + currentRowKey, + formatDuration, + relativeTime, + expectedPagesOf, + pageStripStyle, + pluralOf, + rowKeyOf, + dotsFit, + clockOf +} from 'c/asyncJobTimelineModel'; + +const BADGES = { + running: 'Running', + queued: 'Queued', + delayed: 'Delayed', + failed: 'Failed', + stopped: 'Stopped', + completed: 'Completed' +}; +const IN_FLIGHT = new Set(['running', 'queued', 'delayed']); +const CLOCK_MS = 1000; +const STATUS_MARKS = { + queued: 'hollow', + delayed: 'hollow', + completed: 'check', + failed: 'cross', + stopped: 'square' +}; +const RUNNING_MARKS = { executing: 'dot', waiting: 'hollow', retrying: 'refresh' }; +const ACTIVITY_TEXT = { + executing: 'a step is executing', + waiting: 'waiting for the platform', + retrying: 'waiting for a retry' +}; +export default class AsyncJobSequence extends LightningElement { + @api sequence; + @api canRequeue = false; + @api defaultExpanded = false; + @api isNew = false; + @api pageRecordId; + @api highlight = { added: [], changed: [] }; + + @api + get collapseToken() { + return this.token; + } + set collapseToken(value) { + if (value !== this.token) this.expandedOverride = null; + this.token = value; + } + + token = 0; + expandedOverride = null; + expandedGaps = new Set(); + headerTopBeforeToggle; + clockTimer; + now = Date.now(); + + connectedCallback() { + // eslint-disable-next-line @lwc/lwc/no-async-operation + this.clockTimer = setInterval(() => this.tickClock(), CLOCK_MS); + } + + // The parent can only keep the header in place if it learns how far it moved before the + // browser paints; renderedCallback runs right after the DOM patch, inside the same pass. + renderedCallback() { + if (this.headerTopBeforeToggle === undefined) return; + const delta = this.template.host.getBoundingClientRect().top - this.headerTopBeforeToggle; + this.headerTopBeforeToggle = undefined; + this.dispatchEvent( + new CustomEvent('sequencetoggle', { detail: { open: this.isExpanded, delta } }) + ); + } + + disconnectedCallback() { + clearInterval(this.clockTimer); + } + + @api + expand() { + this.expandedOverride = true; + } + + @api + scrollTo() { + if (typeof this.template.host?.scrollIntoView === 'function') + this.template.host.scrollIntoView({ block: 'start', behavior: 'smooth' }); + } + + targetRowKey = null; + + get rows() { + return groupSteps(this.sequence?.steps || []); + } + + get summary() { + return sequenceSummary(this.rows); + } + + get nameByCustomJobId() { + const names = {}; + (this.sequence?.steps || []).forEach(step => { + names[step.customJobId] = step.jobName || step.className; + }); + return names; + } + + get isInFlight() { + return IN_FLIGHT.has(this.summary.status); + } + + get isExpanded() { + if (this.expandedOverride !== null) return this.expandedOverride; + return Boolean(this.defaultExpanded); + } + + get chainClass() { + return this.isExpanded ? 'chain is-open' : 'chain'; + } + + get toggleIcon() { + return this.isExpanded ? 'utility:chevrondown' : 'utility:chevronright'; + } + + get caretLabel() { + return this.isExpanded ? 'Collapse' : 'Expand'; + } + + get badgeLabel() { + return BADGES[this.summary.status]; + } + + get badgeClass() { + return `chain-badge chain-badge-${this.summary.status}`; + } + + get badgeMarkClass() { + const { status, activity } = this.summary; + const mark = status === 'running' ? RUNNING_MARKS[activity] : STATUS_MARKS[status]; + return `mark mark-${mark}`; + } + + get badgeRetrying() { + const { status, activity } = this.summary; + return status === 'running' && activity === 'retrying'; + } + + get failedSoFar() { + const { status, failedSteps } = this.summary; + return status === 'running' ? failedSteps : 0; + } + + get delayedTail() { + const { status, activity, delayedUntil } = this.summary; + return status === 'running' && activity !== 'executing' && Boolean(delayedUntil); + } + + get badgeTitle() { + const { status, activity, failedSteps } = this.summary; + if (status === 'running') { + const failed = failedSteps ? ` · ${failedSteps} failed so far` : ''; + return `Running · ${ACTIVITY_TEXT[activity]}${failed}`; + } + if (status === 'queued') return 'Queued · nothing has started yet'; + if (status === 'delayed') return 'Delayed · nothing has started yet'; + return failedSteps ? `${BADGES[status]} · ${failedSteps} failed` : BADGES[status]; + } + + get title() { + return this.sequence?.name || 'Async jobs'; + } + + get replayOf() { + const replayed = (this.sequence?.steps || []).find(step => step.requeuedFrom); + if (!replayed) return null; + return `Replay of ${replayed.requeuedFromName || 'an earlier result'}`; + } + + get started() { + return relativeTime(this.sequence?.startedAt, this.now); + } + + get startedText() { + return this.started?.text || ''; + } + + get startedFull() { + return this.started?.full || ''; + } + + get subline() { + const summary = this.summary; + const parts = []; + if (summary.durationMs !== null && !this.isInFlight) + parts.push(formatDuration(summary.durationMs)); + if (this.summary.status === 'delayed') { + parts.push(`starts at ${clockOf(summary.delayedUntil)}`); + } else if (this.delayedTail) { + parts.push(`next step at ${clockOf(summary.delayedUntil)}`); + } else if (this.isInFlight && this.sequence?.startedAt) { + const elapsed = this.now - Date.parse(this.sequence.startedAt); + const verb = summary.status === 'running' ? 'running' : 'queued for'; + if (elapsed > 0) parts.push(`${verb} ${formatDuration(elapsed)}`); + } + parts.push(`${summary.total} step${summary.total === 1 ? '' : 's'}`); + const pages = this.rows + .filter(row => row.kind === 'chunk') + .reduce((sum, row) => sum + expectedPagesOf(row), 0); + if (pages) parts.push(`${pages} page${pages === 1 ? '' : 's'}`); + return parts.join(' · '); + } + + get progressText() { + const progress = this.summary.progress; + const parts = []; + if (progress.steps) + parts.push( + `${progress.stepsCompleted} of ${pluralOf(progress.steps, 'step')} completed` + ); + if (progress.pages) + parts.push(`${progress.pagesDone} of ${pluralOf(progress.pages, 'page')}`); + if (progress.failed) parts.push(`${progress.failed} failed`); + if (progress.skipped) parts.push(`${progress.skipped} skipped`); + if (progress.running) parts.push(`${progress.running} running`); + if (progress.waiting) parts.push(`${progress.waiting} waiting`); + return parts.join(' · '); + } + + get percentLabel() { + return `${this.summary.percent} %`; + } + + get barSegments() { + const progress = this.summary.progress; + const width = count => (progress.total === 0 ? 0 : (count / progress.total) * 100); + return [ + { key: 'ok', className: 'seg seg-ok', style: `width:${width(progress.completed)}%` }, + { key: 'bad', className: 'seg seg-bad', style: `width:${width(progress.failed)}%` }, + { key: 'skip', className: 'seg seg-skip', style: `width:${width(progress.skipped)}%` }, + { key: 'run', className: 'seg seg-run', style: `width:${width(progress.running)}%` } + ].filter(segment => !segment.style.endsWith(':0%')); + } + + get dotsFit() { + return dotsFit(this.rows); + } + + get dots() { + return this.rows.map(row => { + if (row.kind === 'chunk') + return { key: row.key, className: 'dot dot-strip', style: pageStripStyle(row) }; + return { key: row.key, className: `dot dot-${row.status.key}`, style: '' }; + }); + } + + get replayableIds() { + return this.rows + .filter(row => row.kind !== 'chunk' && ['bad', 'skip'].includes(row.status.key)) + .filter(row => row.step.requeueStatus === 'Stored') + .map(row => row.step.id); + } + + get showRequeueFailed() { + return this.canRequeue && !this.isInFlight && this.replayableIds.length > 0; + } + + get requeueCount() { + return String(this.replayableIds.length); + } + + get requeueLabel() { + const count = this.replayableIds.length; + return `Requeue failed step${count === 1 ? '' : 's'} (${count})`; + } + + get currentKey() { + return this.isInFlight ? currentRowKey(this.rows) : null; + } + + get visibleRows() { + let rows = collapseCompletedRuns(this.rows); + rows = rows.flatMap(row => { + return row.kind === 'gap' && this.expandedGaps.has(row.key) ? row.hiddenRows : [row]; + }); + const steps = rows.filter(row => row.kind !== 'gap'); + if (steps.length > CAPS.stepsMax) { + let kept = 0; + rows = rows.filter(row => { + if (row.kind === 'gap') return true; + kept += 1; + return kept <= CAPS.stepsMax; + }); + } + return rows.map((row, index) => ({ + ...this.viewOf(row), + isLastRow: index === rows.length - 1 + })); + } + + get capNote() { + const total = this.rows.length; + return total > CAPS.stepsMax + ? `Showing ${CAPS.stepsMax} of ${total} steps. Open the first result and follow the chain for the rest.` + : null; + } + + tickClock() { + const now = Date.now(); + const startedTextChanges = + relativeTime(this.sequence?.startedAt, now)?.text !== this.startedText; + if (this.isInFlight || startedTextChanges) this.now = now; + } + + viewOf(row) { + if (row.kind === 'gap') { + return { + key: row.key, + isGap: true, + label: `${pluralOf(row.hiddenCount, 'completed step')} hidden · Show` + }; + } + return { + key: row.key, + isGap: false, + row, + isNew: this.highlight?.added?.includes(row.key) || false, + isChanged: this.highlight?.changed?.includes(row.key) || false, + isCurrent: row.key === this.currentKey, + isTarget: row.key === this.targetRowKey + }; + } + + handleTagTarget(event) { + this.targetRowKey = rowKeyOf(this.rows, event.detail.jobId); + } + + toggleExpanded(event) { + event?.stopPropagation(); + const open = !this.isExpanded; + this.headerTopBeforeToggle = this.template.host.getBoundingClientRect().top; + this.expandedOverride = open; + if (!open) this.expandedGaps = new Set(); + } + + handleHeadClick() { + this.toggleExpanded(); + } + + stopHeadClick(event) { + event.stopPropagation(); + } + + expandGap(event) { + const key = event.currentTarget.dataset.key; + this.expandedGaps = new Set([...this.expandedGaps, key]); + } + + handleRequeueFailed() { + const count = this.replayableIds.length; + this.dispatchEvent( + new CustomEvent('requeue', { + detail: { + resultIds: this.replayableIds, + label: `${count} failed step${count === 1 ? '' : 's'} of ${this.title}` + } + }) + ); + } + + handleStepRequeue(event) { + event.stopPropagation(); + this.dispatchEvent(new CustomEvent('requeue', { detail: event.detail })); + } + + handleOpenReplay(event) { + event.stopPropagation(); + this.dispatchEvent(new CustomEvent('openreplay', { detail: event.detail })); + } + + handleMenu(event) { + const chainId = this.sequence.chainId; + const first = this.rows[0]?.step; + const actions = { + map: () => this.dispatchEvent(new CustomEvent('openmap', { detail: { chainId } })), + result: () => + first && + this.dispatchEvent( + new CustomEvent('openresult', { detail: { resultId: first.id } }) + ), + copy: () => navigator.clipboard?.writeText(chainId).catch(() => {}) + }; + actions[event.detail.value]?.(); + } +} diff --git a/force-app/main/default/lwc/asyncJobSequence/asyncJobSequence.js-meta.xml b/force-app/main/default/lwc/asyncJobSequence/asyncJobSequence.js-meta.xml new file mode 100644 index 0000000..00a544f --- /dev/null +++ b/force-app/main/default/lwc/asyncJobSequence/asyncJobSequence.js-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + false + diff --git a/force-app/main/default/lwc/asyncJobStep/__tests__/asyncJobStep.test.js b/force-app/main/default/lwc/asyncJobStep/__tests__/asyncJobStep.test.js new file mode 100644 index 0000000..75d3803 --- /dev/null +++ b/force-app/main/default/lwc/asyncJobStep/__tests__/asyncJobStep.test.js @@ -0,0 +1,610 @@ +import { createElement } from 'lwc'; +import AsyncJobStep from 'c/asyncJobStep'; +import { groupSteps, CAPS } from 'c/asyncJobTimelineModel'; +import { step, flushPromises } from '../../asyncJobTimeline/__tests__/data/fixtures'; + +class StepPO { + constructor(el) { + this.el = el; + } + byId(id) { + return this.el.shadowRoot.querySelector(`[data-id="${id}"]`); + } + all(selector) { + return [...this.el.shadowRoot.querySelectorAll(selector)]; + } +} + +const rowOf = (steps, index = 0) => groupSteps(steps)[index]; + +const mount = async (row, props = {}) => { + const el = createElement('c-async-job-step', { is: AsyncJobStep }); + el.row = row; + el.nameByCustomJobId = { c1: 'Create invoice' }; + el.canRequeue = true; + Object.assign(el, props); + document.body.appendChild(el); + await flushPromises(); + return new StepPO(el); +}; + +describe('c-async-job-step', () => { + afterEach(() => { + while (document.body.firstChild) document.body.removeChild(document.body.firstChild); + }); + + it('renders glyph, name and status for a completed job, with the class behind details', async () => { + const po = await mount( + rowOf([ + step({ chainSequence: 1, jobName: 'Reserve stock', className: 'ReserveStockJob' }) + ]) + ); + expect(po.byId('glyph').dataset.key).toBe('ok'); + expect(po.byId('name').textContent).toContain('Reserve stock'); + expect(po.byId('name').title).toContain('ReserveStockJob'); + expect(po.byId('class')).toBeNull(); + expect(po.byId('status').textContent).toBe('Completed'); + expect(po.byId('duration').textContent).toContain('2 s'); + po.byId('expand').click(); + await flushPromises(); + expect(po.byId('class').textContent).toContain('ReserveStockJob'); + expect(po.byId('details').textContent).toContain('job-'); + }); + + it('shows the class as the name when the job has no name', async () => { + const po = await mount(rowOf([step({ chainSequence: 1, jobName: null })])); + expect(po.byId('name').textContent).toContain('StepJob'); + }); + + it('shows at most two tags', async () => { + const row = rowOf([ + step({ + chainSequence: 2, + addedByCustomJobId: 'c1', + priority: 1, + dependencies: JSON.stringify([ + { resolvedTargetCustomJobId: 'c1', requiredOutcome: 'SUCCESS' } + ]) + }) + ]); + const po = await mount(row); + expect(po.all('[data-id="tags"] .tag')).toHaveLength(2); + }); + + it('expands a failed row into the exception box, truncates long messages and offers requeue', async () => { + const po = await mount( + rowOf([ + step({ + chainSequence: 3, + status: 'FAILED', + exceptionType: 'System.DmlException', + exceptionMessage: 'x'.repeat(500), + retryAttempts: 2, + retryHistory: 'Attempt 1\nAttempt 2', + requeueStatus: 'Stored' + }) + ]) + ); + expect(po.byId('failbox')).toBeNull(); + po.byId('expand').click(); + await flushPromises(); + expect(po.byId('failbox')).not.toBeNull(); + expect(po.byId('exception').textContent).toHaveLength(CAPS.exceptionChars + 1); + po.byId('more').click(); + await flushPromises(); + expect(po.byId('exception').textContent).toHaveLength(500); + expect(po.byId('attempt').textContent).toContain('attempt 3'); + expect(po.byId('requeue')).not.toBeNull(); + }); + + describe('failed pages of a chunk run', () => { + const chunkRunFailing = (failing, total = 15) => + rowOf( + Array.from({ length: total }, (_, index) => { + const number = index + 1; + const failedOne = failing.includes(number); + return step({ + id: `result-${number}`, + name: `AR-${number}`, + customJobId: `p${number}`, + chunkRunId: 'run', + chunkPage: number, + chunkPages: total, + status: failedOne ? 'FAILED' : 'COMPLETED', + exceptionType: failedOne ? 'System.DmlException' : undefined, + exceptionMessage: failedOne ? 'UNABLE_TO_LOCK_ROW' : undefined + }); + }) + ); + + const opened = async row => { + const po = await mount(row); + po.byId('expand').click(); + await flushPromises(); + return po; + }; + + it('names a failed page and shows its own error, not a bare number', async () => { + const po = await opened(chunkRunFailing([7])); + expect(po.byId('failed-group-title').textContent).toBe('Page 7 failed'); + expect(po.byId('failed-group').textContent).toContain('System.DmlException'); + expect(po.byId('failed-group').textContent).toContain('UNABLE_TO_LOCK_ROW'); + }); + + it('lists pages sharing an error in one sentence with a link to each result', async () => { + const po = await opened(chunkRunFailing([3, 7, 9])); + expect(po.byId('failed-group-title').textContent).toBe('Pages 3, 7, 9 failed'); + const links = po.all('[data-id="failed-page-result"]'); + expect(links.map(link => link.textContent)).toEqual(['AR-3', 'AR-7', 'AR-9']); + expect(links[1].getAttribute('href')).toBe('/result-7'); + }); + + it('says how many more failed above the listed cap', async () => { + const failing = Array.from({ length: 13 }, (_, index) => index + 1); + const po = await opened(chunkRunFailing(failing)); + expect(po.byId('failed-more').textContent).toBe('and 3 more failed'); + }); + + it('does not repeat the error when the first page is the one that failed', async () => { + const po = await opened(chunkRunFailing([1])); + expect(po.byId('exception')).toBeNull(); + expect(po.all('[data-id="failed-group"]')).toHaveLength(1); + }); + + it('says the footer link opens the first page', async () => { + const po = await opened(chunkRunFailing([7])); + expect(po.byId('open-result').textContent).toBe('Open first page result AR-1'); + }); + }); + + it('offers requeue as an icon too, firing the same event', async () => { + const po = await mount( + rowOf([step({ customJobId: 'x', status: 'FAILED', requeueStatus: 'Stored' })]) + ); + const handler = jest.fn(); + po.el.addEventListener('requeue', handler); + const compact = po.byId('requeue-compact'); + expect(compact.iconName).toBe('utility:refresh'); + expect(compact.title).toBe('Requeue'); + compact.click(); + expect(handler).toHaveBeenCalledTimes(1); + }); + + describe('tags', () => { + const dependent = (overrides = {}) => + rowOf([ + step({ + customJobId: 'x', + addedByCustomJobId: 'c1', + priority: 1, + dependencies: JSON.stringify([ + { resolvedTargetCustomJobId: 'c1', requiredOutcome: 'SUCCESS' } + ]), + ...overrides + }) + ]); + + it('shows two tags and keeps the rest behind a count with their text on hover', async () => { + const po = await mount(dependent()); + expect(po.all('[data-id="tags"] .tag')).toHaveLength(2); + expect(po.byId('tags-more').textContent).toBe('+1'); + expect(po.byId('tags-more').title).toBe('P1'); + }); + + it('shows no count when every tag fits', async () => { + const po = await mount(dependent({ priority: null })); + expect(po.byId('tags-more')).toBeNull(); + }); + + it('announces the job a tag points at on hover and focus, and clears it after', async () => { + const po = await mount(dependent()); + const targets = []; + po.el.addEventListener('tagtarget', event => targets.push(event.detail.jobId)); + const tag = po.all('[data-id="tags"] .tag')[1]; + tag.dispatchEvent(new CustomEvent('mouseenter')); + tag.dispatchEvent(new CustomEvent('mouseleave')); + tag.dispatchEvent(new CustomEvent('focus')); + tag.dispatchEvent(new CustomEvent('blur')); + expect(targets).toEqual(['c1', null, 'c1', null]); + }); + + it('stays quiet on a tag that points at nothing', async () => { + const po = await mount(rowOf([step({ customJobId: 'x', priority: 1 })])); + const targets = []; + po.el.addEventListener('tagtarget', event => targets.push(event.detail.jobId)); + po.all('[data-id="tags"] .tag')[0].dispatchEvent(new CustomEvent('mouseenter')); + expect(targets).toEqual([]); + }); + }); + + it('marks the target of a hovered tag and the last row of the rail', async () => { + const po = await mount(rowOf([step({ customJobId: 'x' })]), { + isTarget: true, + isLastRow: true + }); + expect(po.byId('row').classList.contains('is-target')).toBe(true); + expect(po.byId('row').classList.contains('is-last-row')).toBe(true); + }); + + it('fires requeue with the result id and a label on one click', async () => { + const po = await mount( + rowOf([ + step({ + chainSequence: 3, + id: 'a0X', + jobName: 'Commission split', + status: 'FAILED', + requeueStatus: 'Stored' + }) + ]) + ); + const handler = jest.fn(); + po.el.addEventListener('requeue', handler); + po.byId('requeue').click(); + expect(handler.mock.calls[0][0].detail).toEqual({ + resultIds: ['a0X'], + label: 'Commission split' + }); + }); + + it('explains why a failed row cannot be requeued instead of offering the button', async () => { + const po = await mount( + rowOf([step({ chainSequence: 3, status: 'FAILED', requeueStatus: 'TooLarge' })]) + ); + expect(po.byId('requeue')).toBeNull(); + expect(po.byId('warning').textContent).toContain('TooLarge'); + }); + + it('marks an already requeued row without a warning or a button', async () => { + const po = await mount( + rowOf([step({ chainSequence: 3, status: 'FAILED', requeueStatus: 'Requeued' })]) + ); + expect(po.byId('requeue')).toBeNull(); + expect(po.byId('warning')).toBeNull(); + expect(po.all('[data-id="tags"] .tag')[0].textContent).toBe('requeued'); + }); + + it('hides the requeue button while the chain is in flight', async () => { + const po = await mount( + rowOf([step({ chainSequence: 3, status: 'FAILED', requeueStatus: 'Stored' })]), + { chainInFlight: true } + ); + expect(po.byId('requeue')).toBeNull(); + }); + + it('opens the details from anywhere on the row', async () => { + const po = await mount(rowOf([step({ chainSequence: 1 })])); + po.byId('row').click(); + await flushPromises(); + expect(po.byId('details')).not.toBeNull(); + po.byId('details').click(); + await flushPromises(); + expect(po.byId('details')).not.toBeNull(); + }); + + it('hides the requeue button without permission', async () => { + const po = await mount( + rowOf([step({ chainSequence: 3, status: 'FAILED', requeueStatus: 'Stored' })]), + { canRequeue: false } + ); + expect(po.byId('requeue')).toBeNull(); + }); + + it('shows three record chips as new-tab links and a +N expander', async () => { + const records = Array.from({ length: 7 }, (_, i) => ({ + key: '00' + i, + name: 'Rec ' + i, + objectName: 'Account', + source: 'Salesforce' + })); + const po = await mount(rowOf([step({ chainSequence: 1, records })])); + expect(po.all('[data-id="chips"] .chip')).toHaveLength(CAPS.chips); + expect(po.all('[data-id="chips"] .chip')[0].getAttribute('href')).toBe('/000'); + expect(po.all('[data-id="chips"] .chip')[0].getAttribute('target')).toBe('_blank'); + expect(po.byId('chips-more').textContent).toContain('+4'); + po.byId('chips-more').click(); + await flushPromises(); + expect(po.all('[data-id="chips"] .chip')).toHaveLength(7); + }); + + it('shows a record from another system as a plain chip naming object and key', async () => { + const records = [ + { key: '4711', source: 'NetSuite', objectName: 'Customer', name: '4711' }, + { key: '4712', source: null, objectName: null, name: '4712' } + ]; + const po = await mount(rowOf([step({ chainSequence: 1, records })])); + const chips = po.all('[data-id="chips"] .chip'); + expect(chips.map(chip => chip.tagName)).toEqual(['SPAN', 'SPAN']); + expect(chips.map(chip => chip.textContent)).toEqual(['Customer 4711', '4712']); + expect(chips.map(chip => chip.getAttribute('title'))).toEqual([ + 'NetSuite Customer', + 'Record in another system' + ]); + }); + + it('hides the chip of the record whose page shows the card', async () => { + const records = [ + { + key: '001000000000001AAA', + name: 'Order #10248', + objectName: 'Account', + source: 'Salesforce' + }, + { + key: '003000000000001AAA', + name: 'Jane Doe', + objectName: 'Contact', + source: 'Salesforce' + } + ]; + const po = await mount(rowOf([step({ chainSequence: 1, records })]), { + pageRecordId: '001000000000001AAA' + }); + expect(po.all('[data-id="chips"] .chip').map(chip => chip.textContent)).toEqual([ + 'Jane Doe' + ]); + expect(po.byId('chips-more')).toBeNull(); + const only = await mount( + rowOf([step({ chainSequence: 1, records: records.slice(0, 1) })]), + { pageRecordId: '001000000000001AAA' } + ); + expect(only.byId('chips')).toBeNull(); + }); + + it('shows only the planned delay until the job is enqueued', async () => { + const po = await mount(rowOf([step({ status: 'ENQUEUED', delayMinutes: 10 })])); + expect(po.byId('status').textContent).toBe('Enqueued'); + expect(po.byId('delay-planned').textContent).toBe('delay 10 min'); + expect(po.byId('delay-until')).toBeNull(); + }); + + it('shows the time and no platform text while the delay runs out', async () => { + const po = await mount( + rowOf([ + step({ + status: 'ENQUEUED', + apexJobStatus: 'Queued', + delayMinutes: 10, + nextAttemptAt: new Date(Date.now() + 300000).toISOString() + }) + ]) + ); + expect(po.byId('status').textContent).toBe('Delayed'); + expect(po.byId('delay-until')).not.toBeNull(); + expect(po.byId('delay-planned')).toBeNull(); + expect(po.byId('platform')).toBeNull(); + expect(po.byId('glyph').dataset.key).toBe('delay'); + }); + + it('says the job waits in the queue, not the flex queue, when plainly queued', async () => { + const po = await mount(rowOf([step({ status: 'ENQUEUED', apexJobStatus: 'Queued' })])); + expect(po.byId('platform').textContent).toBe('waiting in the queue'); + }); + + it('lists the delay in the details', async () => { + const po = await mount(rowOf([step({ status: 'COMPLETED', delayMinutes: 10 })])); + po.byId('expand').click(); + await flushPromises(); + expect(po.byId('details').textContent).toContain('Delay10 min'); + }); + + it('times a chunk run from its first start to its last finish', async () => { + const page = (number, startedAt, finishedAt) => + step({ + chainSequence: 2, + customJobId: 'p' + number, + chunkRunId: 'p1', + chunkPage: number, + chunkPages: 2, + status: 'COMPLETED', + startedAt, + finishedAt + }); + const startOfRun = '2026-11-06T10:00:00.000Z'; + const endOfRun = '2026-11-06T10:00:26.000Z'; + const po = await mount( + rowOf([ + page(1, startOfRun, '2026-11-06T10:00:05.000Z'), + page(2, '2026-11-06T10:00:06.000Z', endOfRun) + ]) + ); + po.byId('expand').click(); + await flushPromises(); + const shown = iso => + new Date(iso).toLocaleString(undefined, { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + const details = po.byId('details').textContent; + expect(details).toContain(`Started${shown(startOfRun)}`); + expect(details).toContain(`Finished${shown(endOfRun)}`); + }); + + it('shows no finish while a chunk run still has pages to go', async () => { + const po = await mount( + rowOf([ + step({ + customJobId: 'p1', + chunkRunId: 'p1', + chunkPage: 1, + chunkPages: 2, + status: 'COMPLETED', + startedAt: '2026-11-06T10:00:00.000Z', + finishedAt: '2026-11-06T10:00:05.000Z' + }) + ]) + ); + po.byId('expand').click(); + await flushPromises(); + expect(po.byId('details').textContent).not.toContain('Finished'); + }); + + it('draws a page strip for a chunk run and a bar above the cap', async () => { + const pages = n => + Array.from({ length: n }, (_, i) => + step({ + chainSequence: 2, + customJobId: 'p' + i, + chunkRunId: 'p0', + chunkPage: i + 1, + chunkPages: n, + status: i < 3 ? 'COMPLETED' : 'ENQUEUED' + }) + ); + const small = await mount(rowOf(pages(12))); + expect(small.all('[data-id="strip"] i')).toHaveLength(12); + expect(small.byId('strip-summary').textContent).toContain('3 of 12 pages'); + const big = await mount(rowOf(pages(CAPS.pageSquares + 1))); + expect(big.byId('strip')).toBeNull(); + expect(big.byId('strip-bar')).not.toBeNull(); + }); + + it('renders a running row with a spinner glyph and the platform job id', async () => { + const po = await mount( + rowOf([ + step({ + chainSequence: 2, + status: 'ENQUEUED', + apexJobStatus: 'Processing', + salesforceJobId: '707000000000001AAA', + finishedAt: null + }) + ]) + ); + expect(po.byId('glyph').dataset.key).toBe('run'); + expect(po.byId('status').textContent).toBe('Running'); + expect(po.byId('platform').textContent).toContain('on the platform'); + expect(po.byId('platform').title).toContain('707000000000001AAA'); + }); + + it('explains a finishing step and spaces the next attempt of a retry', async () => { + const finishing = await mount( + rowOf([ + step({ + chainSequence: 2, + status: 'ENQUEUED', + apexJobStatus: 'Completed', + salesforceJobId: '707000000000001AAA', + finishedAt: null + }) + ]) + ); + expect(finishing.byId('status').textContent).toBe('Finishing'); + expect(finishing.byId('platform').textContent).toContain('result pending'); + const retrying = await mount( + rowOf([ + step({ + chainSequence: 2, + status: 'ENQUEUED', + retryAttempts: 1, + nextAttemptAt: '2026-11-02T09:10:00.000Z', + finishedAt: null + }) + ]) + ); + expect(retrying.byId('next-attempt').textContent).toContain('next attempt\u00a0'); + }); + + it('unions the records of every chunk page into the chips', async () => { + const order = { + key: '001000000000001AAA', + name: 'Order', + objectName: 'Account', + source: 'Salesforce' + }; + const pages = [1, 2].map(n => + step({ + chainSequence: 2, + customJobId: 'pg' + n, + chunkRunId: 'run', + chunkPage: n, + chunkPages: 2, + records: [ + order, + { + key: '00100000000009' + n + 'AAA', + name: 'Line ' + n, + objectName: 'Account', + source: 'Salesforce' + } + ] + }) + ); + const po = await mount(rowOf(pages)); + expect(po.all('[data-id="chips"] .chip').map(chip => chip.textContent)).toEqual([ + 'Order', + 'Line 1', + 'Line 2' + ]); + }); + + it('opens the details from the name row, but not from a record chip', async () => { + const records = [ + { + key: '001000000000001AAA', + name: 'Order', + objectName: 'Account', + source: 'Salesforce' + } + ]; + const po = await mount(rowOf([step({ chainSequence: 1, records })])); + po.byId('name-line').click(); + await flushPromises(); + expect(po.byId('details')).not.toBeNull(); + po.all('[data-id="chips"] .chip')[0].dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }) + ); + await flushPromises(); + expect(po.byId('details')).not.toBeNull(); + }); + + it('offers a link to the replay of a requeued row', async () => { + const po = await mount( + rowOf([ + step({ + chainSequence: 3, + status: 'FAILED', + requeueStatus: 'Requeued', + replayResultId: 'a0X000000000002AAA', + replayChainId: 'chain-replay' + }) + ]) + ); + const handler = jest.fn(); + po.el.addEventListener('openreplay', handler); + po.byId('open-replay').click(); + expect(handler.mock.calls[0][0].detail).toEqual({ + resultId: 'a0X000000000002AAA', + chainId: 'chain-replay' + }); + }); + + it('applies new, changed and current classes', async () => { + const po = await mount(rowOf([step({ chainSequence: 2, status: 'ENQUEUED' })]), { + isNew: true, + isChanged: true, + isCurrent: true + }); + const root = po.byId('row'); + expect(root.classList.contains('is-new')).toBe(true); + expect(root.classList.contains('is-changed')).toBe(true); + expect(root.classList.contains('is-current')).toBe(true); + expect(po.byId('new-badge')).not.toBeNull(); + }); + + it('indents a finalizer under its parent', async () => { + const rows = groupSteps([ + step({ chainSequence: 1, customJobId: 'c1' }), + step({ chainSequence: 1, customJobId: 'f', parentCustomJobId: 'c1', jobName: 'Audit' }) + ]); + const po = await mount(rows[1]); + expect(po.byId('row').classList.contains('is-sub')).toBe(true); + expect(po.byId('glyph').classList.contains('glyph')).toBe(true); + expect(po.all('[data-id="tags"] .tag')[0].textContent).toContain( + 'finalizer of Create invoice' + ); + }); +}); diff --git a/force-app/main/default/lwc/asyncJobStep/asyncJobStep.css b/force-app/main/default/lwc/asyncJobStep/asyncJobStep.css new file mode 100644 index 0000000..427b96b --- /dev/null +++ b/force-app/main/default/lwc/asyncJobStep/asyncJobStep.css @@ -0,0 +1,479 @@ +:host { + --ok: #2a7a44; + --bad: #ba0517; + --warn: #dd7a01; + --wait: #939393; + --delay: #8a5a00; + --run: #016dc3; + --muted: #666; + --line: #d8dde6; + --rail: #c9d4e0; + --rail-x: 1rem; + display: block; +} +.step { + --ring: #f3f6f9; + display: grid; + grid-template-columns: 2rem 1fr auto; + gap: 0 0.5rem; + align-items: center; + padding: 0.5rem 0; + position: relative; + border-radius: 0.25rem; +} +.step:not(.is-last-row)::before { + content: ''; + position: absolute; + left: calc(var(--rail-x) - 1px); + top: 0; + bottom: 0; + width: 2px; + background: var(--rail); +} +.glyph-cell { + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} +.step.is-last-row .glyph-cell::before { + content: ''; + position: absolute; + left: calc(var(--rail-x) - 1px); + top: -0.5rem; + height: calc(50% + 0.5rem); + width: 2px; + background: var(--rail); +} +.step.is-sub .glyph-cell::after { + content: ''; + position: absolute; + left: calc(var(--rail-x) - 2.25rem); + right: 50%; + top: calc(50% - 1px); + height: 2px; + background: var(--rail); +} +.step.is-sub.is-last-row .glyph-cell::before { + left: calc(var(--rail-x) - 2.25rem - 1px); + height: calc(50% + 0.5rem + 1px); +} +.details-row { + grid-column: 2 / -1; +} +.step.is-sub { + padding-left: 2.25rem; +} +.step.is-sub .name { + font-weight: 500; +} +.step.is-current { + outline: 2px solid var(--run); + outline-offset: 2px; + z-index: 1; +} +.step.is-target { + --ring: #d8e6fb; + background: #d8e6fb; +} +.step.is-new { + animation: appear 0.6s ease-out; + background: linear-gradient(90deg, #fff7e0, transparent 70%); +} +.step.is-changed { + animation: flash 1.2s ease-out; +} +@keyframes appear { + from { + opacity: 0; + transform: translateX(-12px); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes flash { + 0% { + background: #eef4ff; + } + 100% { + background: transparent; + } +} +@media (prefers-reduced-motion: reduce) { + .step.is-new, + .step.is-changed, + .glyph-run::after { + animation: none !important; + } +} +.glyph { + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0 auto; + align-self: center; + font-size: 0.75rem; + font-weight: 700; + color: #fff; + box-sizing: border-box; + position: relative; + z-index: 1; + box-shadow: 0 0 0 3px var(--ring); +} +.glyph-ok { + background: var(--ok); +} +.glyph-bad { + background: var(--bad); +} +.glyph-skip { + background: var(--warn); +} +.glyph-wait, +.glyph-retry { + background: #fff; + border: 2px solid var(--wait); + color: var(--wait); +} +.glyph-delay { + background: #fff; + border: 2px solid var(--delay); + color: var(--delay); + font-size: 0.625rem; +} +.glyph-run { + background: #fff; + border: 2px solid var(--run); +} +.glyph-run::after { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + border: 2px solid var(--run); + border-right-color: transparent; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.main { + min-width: 0; +} +.step { + cursor: pointer; +} +.step:hover { + --ring: #fafafa; + background: #fafafa; +} +.step.is-target:hover { + --ring: #d8e6fb; + background: #d8e6fb; +} +.details-row, +.right { + cursor: default; +} +.name-line { + display: flex; + gap: 0.25rem 0.5rem; + align-items: center; + flex-wrap: wrap; +} +.name { + font-size: 0.875rem; + font-weight: 600; +} +.status { + font-size: 0.75rem; + font-weight: 600; +} +.badge-new { + background: #fde7a0; + color: #5c3b00; + border-radius: 0.25rem; + font-size: 0.6875rem; + font-weight: 700; + padding: 0 0.375rem; + text-transform: uppercase; +} +.tags { + display: inline-flex; + gap: 0.25rem; + flex-wrap: wrap; +} +.tag { + font-size: 0.6875rem; + color: #444; + background: #fff; + border: 1px solid var(--line); + border-radius: 0.25rem; + padding: 0 0.375rem; + line-height: 1.3rem; + white-space: nowrap; +} +.tag-pointer:hover, +.tag-pointer:focus-visible { + border-color: var(--run); + color: var(--run); + outline: none; +} +.tag-more { + font-size: 0.6875rem; + color: var(--muted); + line-height: 1.3rem; + cursor: default; +} +.meta { + font-size: 0.75rem; + color: var(--muted); + display: flex; + gap: 0.25rem 0.75rem; + flex-wrap: wrap; + align-items: center; + font-variant-numeric: tabular-nums; + margin-top: 0.125rem; +} +.platform { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.strip { + display: inline-flex; + gap: 2px; + vertical-align: middle; + flex-wrap: wrap; + max-width: 24rem; +} +.strip i { + width: 7px; + height: 7px; + border-radius: 1px; + background: var(--line); + display: block; +} +.sq-delay { + background: var(--delay); +} +.sq-ok { + background: var(--ok) !important; +} +.sq-bad { + background: var(--bad) !important; +} +.sq-skip { + background: var(--warn) !important; +} +.sq-run, +.sq-retry { + background: var(--run) !important; +} +.strip-bar { + display: inline-block; + width: 8rem; + height: 6px; + border-radius: 3px; + background: var(--line); + overflow: hidden; + vertical-align: middle; +} +.chips { + display: inline-flex; + gap: 0.25rem; + flex-wrap: wrap; +} +.chip { + font-size: 0.6875rem; + color: var(--run); + background: #eef4ff; + border-radius: 0.75rem; + padding: 0 0.5rem; + border: 0; + line-height: 1.25rem; + text-decoration: none; +} +.chip-external { + color: #444; + background: #fff; + cursor: default; +} +.chip:hover, +.chip:focus-visible { + text-decoration: underline; + background: #d8e6fb; +} +.replay-link { + margin-top: 0.25rem; + font-weight: 600; +} +.chip-more { + font-size: 0.6875rem; + color: var(--run); + background: #eef4ff; + border-radius: 0.75rem; + padding: 0 0.5rem; + border: 0; + line-height: 1.25rem; + cursor: pointer; +} +.warn { + font-size: 0.75rem; + color: #8c4b02; + background: #fef8ec; + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + margin-top: 0.25rem; +} +.skip-reason { + font-size: 0.75rem; + color: var(--muted); + margin-top: 0.125rem; +} +.details { + margin-top: 0.375rem; + border: 1px solid var(--line); + border-radius: 0.25rem; + background: #fff; + padding: 0.5rem 0.75rem; + font-size: 0.75rem; +} +.facts { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: 0.25rem 1rem; + margin: 0; +} +.fact { + display: grid; + grid-template-columns: 6.5rem 1fr; + gap: 0.5rem; + min-width: 0; +} +.fact dt { + color: var(--muted); +} +.fact dd { + margin: 0; + overflow-wrap: anywhere; +} +.fact dd.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.details-foot { + margin-top: 0.5rem; +} +.failbox { + margin-top: 0.5rem; + border: 1px solid #f3b7b0; + border-left: 3px solid var(--bad); + background: #fef1ee; + border-radius: 0.25rem; + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; +} +.exception-type { + font-weight: 700; +} +.failbox code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; + word-break: break-word; + white-space: pre-wrap; +} +.failed-group + .failed-group, +.failed-group + [data-id='failed-more'] { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid #f3b7b0; +} +.failed-group-title { + font-weight: 700; + color: var(--bad); +} +.failed-group-links { + display: flex; + flex-wrap: wrap; + gap: 0 0.5rem; + margin-top: 0.25rem; + font-size: 0.75rem; +} +.retry-history { + margin: 0.375rem 0 0; + padding-left: 1rem; + color: var(--muted); + font-size: 0.75rem; +} +.right { + white-space: nowrap; + display: flex; + align-items: center; + gap: 0.25rem; +} +.status-ok { + color: var(--ok); +} +.status-bad { + color: var(--bad); +} +.status-skip { + color: var(--warn); +} +.status-delay { + color: var(--delay); +} +.status-wait, +.status-retry { + color: var(--muted); +} +.status-run { + color: var(--run); +} +.link { + background: none; + border: 0; + padding: 0; + color: var(--run); + cursor: pointer; + font-size: 0.75rem; +} +.link:focus-visible, +.chip-more:focus-visible { + outline: 2px solid var(--run); + outline-offset: 1px; +} +@container (min-width: 641px) { + .compact-only { + display: none; + } +} +@container (max-width: 640px) { + .full-only { + display: none; + } + .glyph { + align-self: flex-start; + } + .right { + align-self: start; + } + .step.is-last-row .glyph-cell::before { + height: 1.125rem; + } + .step.is-sub .glyph-cell::after { + top: calc(0.625rem - 1px); + } + .step.is-sub.is-last-row .glyph-cell::before { + height: calc(1.125rem + 1px); + } + .fact { + grid-template-columns: 5.5rem 1fr; + } +} diff --git a/force-app/main/default/lwc/asyncJobStep/asyncJobStep.html b/force-app/main/default/lwc/asyncJobStep/asyncJobStep.html new file mode 100644 index 0000000..c1c7c2b --- /dev/null +++ b/force-app/main/default/lwc/asyncJobStep/asyncJobStep.html @@ -0,0 +1,307 @@ + diff --git a/force-app/main/default/lwc/asyncJobStep/asyncJobStep.js b/force-app/main/default/lwc/asyncJobStep/asyncJobStep.js new file mode 100644 index 0000000..b9665f7 --- /dev/null +++ b/force-app/main/default/lwc/asyncJobStep/asyncJobStep.js @@ -0,0 +1,449 @@ +import USER_LOCALE from '@salesforce/i18n/locale'; +import { LightningElement, api } from 'lwc'; +import { + CAPS, + tagsFor, + failedPageGroups, + formatDuration, + pageStripFor, + delayOf, + chunkRunAlive, + recordsOf, + truncate, + sameData, + pageStripStyle, + SOURCE_SALESFORCE +} from 'c/asyncJobTimelineModel'; + +const GLYPHS = { ok: '✓', bad: '✕', skip: '→', wait: '○', delay: '◷', run: '', retry: '↻' }; +const EXPLAINED_STATUSES = new Set(['FAILED', 'FRAMEWORK_ERROR', 'SKIPPED_DEPENDENCY']); +const PLATFORM_LABELS = { + Queued: 'waiting in the queue', + Holding: 'holding in the flex queue', + Preparing: 'on the platform', + Processing: 'on the platform', + Completed: 'result pending · the finalizer writes it', + Aborted: 'aborted on the platform', + Failed: 'failed on the platform' +}; +const OUTCOME_WORDS = { SUCCESS: 'succeeded', FAILURE: 'failed', COMPLETED: 'finished' }; + +const chipOf = record => { + const inSalesforce = record.source === SOURCE_SALESFORCE; + const elsewhere = [record.source, record.objectName].filter(Boolean).join(' '); + return { + key: `${record.source || ''}|${record.key}`, + label: inSalesforce + ? record.name + : [record.objectName, record.key].filter(Boolean).join(' '), + url: inSalesforce ? '/' + record.key : null, + title: inSalesforce + ? `${record.objectName || 'Record'} · opens in a new tab` + : elsewhere || 'Record in another system' + }; +}; + +export default class AsyncJobStep extends LightningElement { + @api canRequeue = false; + @api chainInFlight = false; + @api isNew = false; + @api isChanged = false; + @api isCurrent = false; + @api isTarget = false; + @api isLastRow = false; + @api pageRecordId; + + @api + get row() { + return this.knownRow; + } + set row(value) { + if (!sameData(value, this.knownRow)) this.knownRow = value; + } + + @api + get nameByCustomJobId() { + return this.knownNames; + } + set nameByCustomJobId(value) { + if (!sameData(value, this.knownNames)) this.knownNames = value; + } + + knownRow; + knownNames = {}; + expanded = false; + showFullException = false; + showAllChips = false; + + get step() { + return this.row.step; + } + + get status() { + return this.row.status; + } + + get rowClass() { + return { + step: true, + 'is-sub': this.row.depth > 0, + 'is-new': this.isNew, + 'is-changed': this.isChanged, + 'is-current': this.isCurrent, + 'is-target': this.isTarget, + 'is-last-row': this.isLastRow + }; + } + + get glyphClass() { + return `glyph glyph-${this.status.key}`; + } + + get glyphText() { + return GLYPHS[this.status.key]; + } + + get statusClass() { + return `status status-${this.status.key}`; + } + + get displayName() { + return this.step.jobName || this.step.className; + } + + get nameTitle() { + return this.step.jobName ? this.step.className : this.displayName; + } + + get allTags() { + return tagsFor(this.row, this.nameByCustomJobId); + } + + get tags() { + return this.allTags.slice(0, CAPS.tags).map(tag => ({ + ...tag, + className: tag.targetJobId ? 'tag tag-pointer' : 'tag', + tabIndex: tag.targetJobId ? 0 : null + })); + } + + get hiddenTags() { + return this.allTags.slice(CAPS.tags).map(tag => tag.label); + } + + get hiddenTagLabel() { + return this.hiddenTags.length ? `+${this.hiddenTags.length}` : null; + } + + get hiddenTagTitle() { + return this.hiddenTags.join(' · '); + } + + showTagTarget(event) { + const jobId = event.currentTarget.dataset.target; + if (jobId) this.dispatchEvent(new CustomEvent('tagtarget', { detail: { jobId } })); + } + + clearTagTarget(event) { + if (!event.currentTarget.dataset.target) return; + this.dispatchEvent(new CustomEvent('tagtarget', { detail: { jobId: null } })); + } + + get timedSteps() { + return this.row.kind === 'chunk' ? this.row.pages : [this.step]; + } + + get startedAtMs() { + const starts = this.timedSteps + .map(page => Date.parse(page.startedAt)) + .filter(Number.isFinite); + return starts.length === 0 ? null : Math.min(...starts); + } + + get finishedAtMs() { + const ends = this.timedSteps + .map(page => Date.parse(page.finishedAt)) + .filter(Number.isFinite); + return ends.length === 0 ? null : Math.max(...ends); + } + + get durationMs() { + if (this.startedAtMs === null || this.finishedAtMs === null) return null; + return this.finishedAtMs - this.startedAtMs; + } + + get runStillGoing() { + return this.row.kind === 'chunk' && chunkRunAlive(this.row); + } + + get duration() { + return formatDuration(this.durationMs); + } + + get platformLabel() { + if (this.status.key === 'delay') return null; + if (this.step.status !== 'ENQUEUED' || !this.step.apexJobStatus) return null; + return PLATFORM_LABELS[this.step.apexJobStatus] || this.step.apexJobStatus; + } + + get platformTitle() { + return `Salesforce job ${this.step.salesforceJobId || ''} · ${this.step.apexJobStatus}`; + } + + get attemptLabel() { + if (!this.step.retryAttempts) return null; + return `attempt ${this.step.retryAttempts + 1}`; + } + + get delayUntil() { + return delayOf(this.row).until; + } + + get plannedDelayLabel() { + const { plannedMinutes } = delayOf(this.row); + return plannedMinutes ? `delay ${plannedMinutes} min` : null; + } + + get nextAttemptAt() { + return this.status.key === 'retry' ? this.step.nextAttemptAt : null; + } + + get isExplained() { + return EXPLAINED_STATUSES.has(this.step.status); + } + + get expandLabel() { + return this.expanded ? 'Hide details' : 'Details'; + } + + get expandIcon() { + return this.expanded ? 'utility:chevronup' : 'utility:chevrondown'; + } + + get exceptionText() { + const message = this.step.exceptionMessage || ''; + return this.showFullException ? message : truncate(message, CAPS.exceptionChars); + } + + get hasMoreException() { + return ( + !this.showFullException && + (this.step.exceptionMessage || '').length > CAPS.exceptionChars + ); + } + + get retryLines() { + return (this.step.retryHistory || '').split('\n').filter(Boolean); + } + + get showOwnException() { + return !this.isChunk && Boolean(this.step.exceptionType || this.step.exceptionMessage); + } + + get showFailure() { + return Boolean( + this.showOwnException || this.step.skipReason || this.failedPages.groups.length + ); + } + + get isOutcomeRow() { + return ['bad', 'skip'].includes(this.status.key) && this.row.kind !== 'chunk'; + } + + get requeueable() { + return ( + this.canRequeue && + !this.chainInFlight && + this.isOutcomeRow && + this.step.requeueStatus === 'Stored' + ); + } + + get warning() { + if (!this.isOutcomeRow || !this.isExplained) return null; + if (['Stored', 'Requeued'].includes(this.step.requeueStatus)) return null; + return this.step.requeueStatus + ? `Cannot be requeued: ${this.step.requeueStatus}. Enqueue it fresh from code.` + : 'Cannot be requeued: payload not stored (StoreJobPayload is not Yes). Enqueue it fresh from code.'; + } + + get skipReasonVisible() { + return this.status.key === 'skip' && this.step.skipReason && !this.expanded; + } + + get resultUrl() { + return '/' + this.step.id; + } + + get linkedElsewhere() { + return recordsOf(this.row).filter(record => record.key !== this.pageRecordId); + } + + get chips() { + const records = this.linkedElsewhere; + const shown = this.showAllChips + ? records.slice(0, CAPS.chipsExpanded) + : records.slice(0, CAPS.chips); + return shown.map(chipOf); + } + + get replayLink() { + return this.step.requeueStatus === 'Requeued' && this.step.replayResultId + ? { resultId: this.step.replayResultId, chainId: this.step.replayChainId } + : null; + } + + get hiddenChipCount() { + return this.showAllChips ? 0 : Math.max(this.linkedElsewhere.length - CAPS.chips, 0); + } + + get hiddenChipLabel() { + return `+${this.hiddenChipCount}`; + } + + get strip() { + return this.row.kind === 'chunk' ? pageStripFor(this.row) : null; + } + + get squares() { + return (this.strip?.squares || []).map(square => ({ + key: square.page, + className: `sq sq-${square.key}`, + title: `page ${square.page}` + })); + } + + get stripBarStyle() { + return pageStripStyle(this.row); + } + + get isChunk() { + return this.row.kind === 'chunk'; + } + + get failedPages() { + return this.isChunk ? failedPageGroups(this.row) : { groups: [], moreFailed: 0 }; + } + + get failedGroups() { + return this.failedPages.groups.map(group => ({ + key: group.key, + exceptionType: group.exceptionType, + message: truncate(group.message, CAPS.exceptionChars), + title: `${group.pages.length > 1 ? 'Pages' : 'Page'} ${group.pages + .map(page => page.chunkPage) + .join(', ')} failed`, + links: group.pages.map(page => ({ + key: page.id, + label: page.name, + url: '/' + page.id + })) + })); + } + + get failedMoreLabel() { + const { moreFailed } = this.failedPages; + return moreFailed ? `and ${moreFailed} more failed` : null; + } + + get resultLinkLabel() { + return `${this.isChunk ? 'Open first page result' : 'Open result'} ${this.step.name}`; + } + + get facts() { + const step = this.step; + const names = this.nameByCustomJobId; + const dependencies = this.dependencies + .map( + dependency => + `after ${names[dependency.resolvedTargetCustomJobId] || 'an earlier job'} ${OUTCOME_WORDS[dependency.requiredOutcome] || 'finished'}` + ) + .join(', '); + const records = recordsOf(this.row).length; + return [ + ['Class', step.className, 'class', 'mono'], + ['Custom job id', step.customJobId, 'custom-job-id', 'mono'], + ['Salesforce job', step.salesforceJobId, 'salesforce-job-id', 'mono'], + ['Position', step.chainSequence ? `#${step.chainSequence}` : null], + [ + 'Priority', + step.priority !== null && step.priority !== undefined ? step.priority : null + ], + [ + 'Pages', + this.row.kind === 'chunk' + ? `${this.row.pages.length} of ${step.chunkPages || this.row.pages.length}` + : null + ], + ['Attempts', step.retryAttempts ? step.retryAttempts + 1 : null], + ['Started', this.formatted(this.startedAtMs)], + ['Finished', this.runStillGoing ? null : this.formatted(this.finishedAtMs)], + ['Duration', this.duration], + ['Delay', step.delayMinutes ? `${step.delayMinutes} min` : null], + ['Next attempt', this.formatted(step.nextAttemptAt)], + ['Depends on', dependencies || null], + ['Requeue', step.requeueStatus], + ['Records', records ? `${records} linked` : null] + ] + .filter(([, value]) => value !== null && value !== undefined && value !== '') + .map(([label, value, id, className]) => ({ + label, + value: String(value), + id: id || label.toLowerCase().replace(/\s+/g, '-'), + className: className || '' + })); + } + + get dependencies() { + if (!this.step.dependencies) return []; + try { + const parsed = JSON.parse(this.step.dependencies); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + formatted(moment) { + const at = new Date(moment); + if (!moment || Number.isNaN(at.getTime())) return null; + return at.toLocaleString(USER_LOCALE, { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } + + toggleExpanded(event) { + event?.stopPropagation(); + this.expanded = !this.expanded; + } + + stopClick(event) { + event.stopPropagation(); + } + + openReplay(event) { + event.stopPropagation(); + this.dispatchEvent(new CustomEvent('openreplay', { detail: this.replayLink })); + } + + showWholeException() { + this.showFullException = true; + } + + showAllRecords(event) { + event?.stopPropagation(); + this.showAllChips = true; + } + + handleRequeue() { + this.dispatchEvent( + new CustomEvent('requeue', { + detail: { resultIds: [this.step.id], label: this.displayName } + }) + ); + } +} diff --git a/force-app/main/default/lwc/asyncJobStep/asyncJobStep.js-meta.xml b/force-app/main/default/lwc/asyncJobStep/asyncJobStep.js-meta.xml new file mode 100644 index 0000000..00a544f --- /dev/null +++ b/force-app/main/default/lwc/asyncJobStep/asyncJobStep.js-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + false + diff --git a/force-app/main/default/lwc/asyncJobTimeline/__tests__/asyncJobTimeline.test.js b/force-app/main/default/lwc/asyncJobTimeline/__tests__/asyncJobTimeline.test.js new file mode 100644 index 0000000..7e450b1 --- /dev/null +++ b/force-app/main/default/lwc/asyncJobTimeline/__tests__/asyncJobTimeline.test.js @@ -0,0 +1,1142 @@ +import { createElement } from 'lwc'; +import AsyncJobTimeline from 'c/asyncJobTimeline'; +import load from '@salesforce/apex/AsyncJobTimelineController.load'; +import poll from '@salesforce/apex/AsyncJobTimelineController.poll'; +import requeue from '@salesforce/apex/AsyncJobTimelineController.requeue'; +import { POLL, VERSION } from 'c/asyncJobTimelineModel'; +import AsyncChainMap from 'c/asyncChainMap'; +import { + page, + failedSequence, + runningSequence, + completedSequence, + flushPromises, + step, + sequence +} from './data/fixtures'; + +jest.mock('lightning/confirm', () => ({ + __esModule: true, + default: { open: jest.fn().mockResolvedValue(true) } +})); +import LightningConfirm from 'lightning/confirm'; + +const mockNavigate = jest.fn(); +jest.mock( + 'lightning/navigation', + () => { + const Navigate = Symbol('Navigate'); + const GenerateUrl = Symbol('GenerateUrl'); + const NavigationMixin = Base => + class extends Base { + [Navigate](pageReference) { + mockNavigate(pageReference); + } + [GenerateUrl]() { + return Promise.resolve('#'); + } + }; + NavigationMixin.Navigate = Navigate; + NavigationMixin.GenerateUrl = GenerateUrl; + return { NavigationMixin, CurrentPageReference: jest.fn() }; + }, + { virtual: true } +); +jest.mock('@salesforce/apex/AsyncJobTimelineController.load', () => ({ default: jest.fn() }), { + virtual: true +}); +jest.mock('@salesforce/apex/AsyncJobTimelineController.poll', () => ({ default: jest.fn() }), { + virtual: true +}); +jest.mock('@salesforce/apex/AsyncJobTimelineController.requeue', () => ({ default: jest.fn() }), { + virtual: true +}); + +class TimelinePO { + constructor(el) { + this.el = el; + } + get root() { + return this.el.shadowRoot; + } + byId(id) { + return this.root.querySelector(`[data-id="${id}"]`); + } + get sequences() { + return [...this.root.querySelectorAll('c-async-job-sequence')]; + } + get liveText() { + return this.byId('live')?.textContent.trim(); + } + chip(name) { + return this.byId('chip-' + name); + } +} + +const mount = async (props = {}) => { + const el = createElement('c-async-job-timeline', { is: AsyncJobTimeline }); + el.recordId = '001000000000001AAA'; + Object.assign(el, props); + document.body.appendChild(el); + await flushPromises(); + return new TimelinePO(el); +}; + +describe('c-async-job-timeline', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] }); + poll.mockResolvedValue({ fingerprint: 'fp-1', apexJobStatusById: {} }); + }); + + afterEach(() => { + while (document.body.firstChild) document.body.removeChild(document.body.firstChild); + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it('shows a loading skeleton until the page arrives', () => { + load.mockReturnValue(new Promise(() => {})); + const el = createElement('c-async-job-timeline', { is: AsyncJobTimeline }); + el.recordId = '001000000000001AAA'; + document.body.appendChild(el); + expect(el.shadowRoot.querySelector('[data-id="loading"]')).not.toBeNull(); + }); + + it('renders one sequence card per chain, prefetching a block of chains for the range', async () => { + load.mockResolvedValue(page([failedSequence(), completedSequence()])); + const po = await mount({ daysBack: 3, chainsPerPage: 5 }); + expect(po.sequences).toHaveLength(2); + expect(load).toHaveBeenCalledWith({ + request: { + recordId: '001000000000001AAA', + daysBack: 3, + chainLimit: 50, + chainOffset: 0, + statusFilter: 'all', + keepChainIds: [] + } + }); + expect(po.byId('loading')).toBeNull(); + }); + + it('expands only the first in-flight sequence by default', async () => { + load.mockResolvedValue(page([failedSequence(), runningSequence(), completedSequence()])); + const po = await mount(); + expect(po.sequences.map(card => card.defaultExpanded)).toEqual([false, true, false]); + }); + + describe('which chains open', () => { + const queuedSequence = () => + sequence('chain-queued', [ + step({ customJobId: 'q1', status: 'ENQUEUED', apexJobStatus: 'Queued' }) + ]); + const running = id => ({ ...runningSequence(), chainId: id }); + const openFlags = po => po.sequences.map(card => card.defaultExpanded); + const toggle = async card => { + card.shadowRoot.querySelector('[data-id="head"]').click(); + await flushPromises(); + }; + const pick = async (po, name) => { + po.chip(name).click(); + await flushPromises(); + }; + + it('opens nothing when nothing runs on the first page', async () => { + load.mockResolvedValue(page([failedSequence(), completedSequence()])); + const po = await mount(); + expect(openFlags(po)).toEqual([false, false]); + }); + + it('opens nothing for a chain that only waits in the queue', async () => { + load.mockResolvedValue(page([queuedSequence(), completedSequence()])); + const po = await mount(); + expect(openFlags(po)).toEqual([false, false]); + }); + + it('opens only the first running chain', async () => { + load.mockResolvedValue(page([running('one'), running('two')])); + const po = await mount(); + expect(openFlags(po)).toEqual([true, false]); + }); + + it('never opens by itself under a filter', async () => { + load.mockResolvedValueOnce(page([completedSequence()])); + const po = await mount(); + load.mockResolvedValue(page([running('one')])); + await pick(po, 'inflight'); + expect(openFlags(po)).toEqual([false]); + }); + + it('never opens a running chain that is not on the first page', async () => { + const finished = Array.from({ length: 7 }, (_, index) => + completedSequence(`done-${index}`) + ); + load.mockResolvedValue(page([...finished, running('later')], { totalChainCount: 8 })); + const po = await mount(); + po.byId('next').click(); + await flushPromises(); + expect(po.sequences.map(card => card.sequence.chainId)).toEqual(['later']); + expect(openFlags(po)).toEqual([false]); + }); + + it('keeps a chain you closed closed after visiting a filter', async () => { + load.mockResolvedValue(page([running('one'), completedSequence()])); + const po = await mount(); + await toggle(po.sequences[0]); + await pick(po, 'failed'); + await pick(po, 'all'); + expect(openFlags(po)).toEqual([false, false]); + expect(po.sequences[0].shadowRoot.querySelector('[data-id="body"]')).toBeNull(); + }); + + it('keeps a chain you opened open after visiting a filter', async () => { + const done = completedSequence('kept'); + load.mockResolvedValue(page([done])); + const po = await mount(); + await toggle(po.sequences[0]); + load.mockResolvedValueOnce(page([])); + await pick(po, 'failed'); + expect(po.sequences).toHaveLength(0); + await pick(po, 'all'); + expect(po.sequences[0].shadowRoot.querySelector('[data-id="body"]')).not.toBeNull(); + }); + + it('keeps a chain you opened open after paging away and back', async () => { + const finished = Array.from({ length: 8 }, (_, index) => + completedSequence(`done-${index}`) + ); + load.mockResolvedValue(page(finished, { totalChainCount: 8 })); + const po = await mount(); + await toggle(po.sequences[1]); + po.byId('next').click(); + await flushPromises(); + po.byId('prev').click(); + await flushPromises(); + expect(po.sequences[1].shadowRoot.querySelector('[data-id="body"]')).not.toBeNull(); + }); + + it('does not open another running chain when you close the open one', async () => { + load.mockResolvedValue(page([running('one'), running('two')])); + const po = await mount(); + await toggle(po.sequences[0]); + po.byId('refresh').click(); + await flushPromises(); + expect(openFlags(po)).toEqual([false, false]); + }); + + it('opens a chain that starts later when nothing else is open', async () => { + load.mockResolvedValueOnce(page([completedSequence()])); + const po = await mount(); + load.mockResolvedValue(page([running('late'), completedSequence()])); + po.byId('refresh').click(); + await flushPromises(); + expect(openFlags(po)).toEqual([true, false]); + }); + }); + + it('explains a missing permission set instead of a raw error', async () => { + load.mockRejectedValue({ + body: { + message: + 'You cannot read async job results. Ask an admin for the Async Result Access permission set.' + } + }); + const po = await mount(); + expect(po.byId('error').textContent).toContain('Async Result Access'); + expect(po.sequences).toHaveLength(0); + }); + + it('shows an empty state with the count of older sequences and the list link', async () => { + load.mockResolvedValue(page([], { olderChainCount: 3 })); + const po = await mount(); + expect(po.byId('empty').textContent).toContain('last 7 days'); + expect(po.byId('older-count').textContent).toContain('3 older'); + expect(po.byId('view-all')).not.toBeNull(); + expect(po.byId('view-all-foot')).toBeNull(); + }); + + it('lays the legend over the list and closes it on Escape, outside click or its X', async () => { + load.mockResolvedValue(page()); + const po = await mount(); + const heightBefore = po.byId('scroller').getAttribute('style'); + po.byId('help').click(); + await flushPromises(); + expect(po.byId('legend').getAttribute('role')).toBe('dialog'); + expect(po.byId('scroller').getAttribute('style')).toBe(heightBefore); + expect(po.byId('legend').parentElement).toBe(po.byId('scroller').parentElement); + po.byId('legend').dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + await flushPromises(); + expect(po.byId('legend')).not.toBeNull(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + await flushPromises(); + expect(po.byId('legend')).toBeNull(); + po.byId('help').click(); + await flushPromises(); + po.byId('scroller').click(); + await flushPromises(); + expect(po.byId('legend')).toBeNull(); + po.byId('help').click(); + await flushPromises(); + po.byId('legend-close').click(); + await flushPromises(); + expect(po.byId('legend')).toBeNull(); + }); + + it('signs the footer with the version and links to the docs and the maker', async () => { + load.mockResolvedValue(page()); + const po = await mount(); + const library = po.byId('brand'); + const maker = po.byId('brand-maker'); + expect(library.textContent.replace(/\s+/g, '')).toBe(`AsyncLibv${VERSION}`); + expect(maker.textContent.replace(/\s+/g, '')).toBe('byBeyondTheCloud'); + expect(library.querySelector('a').href).toBe('https://async.beyondthecloud.dev/'); + expect(maker.querySelector('a').href).toBe('https://beyondthecloud.dev/'); + expect(po.root.querySelector('.logo img').getAttribute('src')).toContain('AsyncLibLogo'); + }); + + it('counts chips over the whole range and filters on the server', async () => { + load.mockResolvedValueOnce( + page([failedSequence(), runningSequence(), completedSequence()], { + counts: { all: 12, inflight: 2, failed: 4, completed: 6 } + }) + ); + load.mockResolvedValueOnce(page([failedSequence()], { totalChainCount: 4 })); + const po = await mount(); + expect(po.chip('all').textContent).toContain('12'); + expect(po.chip('inflight').textContent).toContain('2'); + expect(po.chip('failed').textContent).toContain('4'); + expect(po.chip('completed').textContent).toContain('6'); + po.chip('failed').click(); + await flushPromises(); + expect(load).toHaveBeenLastCalledWith({ + request: expect.objectContaining({ statusFilter: 'failed', chainOffset: 0 }) + }); + expect(po.sequences).toHaveLength(1); + expect(po.sequences[0].sequence.chainId).toBe('chain-failed'); + }); + + describe('In flight chip', () => { + const withCounts = (inflight, sequences = [completedSequence('a')]) => + page(sequences, { + counts: { all: 3, inflight, failed: 0, completed: 3 - inflight }, + totalChainCount: sequences.length + }); + const dotOf = po => po.chip('inflight').querySelector('[data-id="chip-dot"]'); + + it('turns live with a pulsing dot while something runs', async () => { + load.mockResolvedValue(withCounts(2, [runningSequence()])); + const po = await mount(); + expect(po.chip('inflight').classList.contains('chip-live')).toBe(true); + expect(dotOf(po).classList.contains('is-paused')).toBe(false); + }); + + it('goes plain when nothing runs', async () => { + load.mockResolvedValue(withCounts(0)); + const po = await mount(); + expect(po.chip('inflight').classList.contains('chip-live')).toBe(false); + expect(dotOf(po)).toBeNull(); + }); + + it('stops pulsing when live refresh is off', async () => { + load.mockResolvedValue(withCounts(2, [runningSequence()])); + const po = await mount({ liveRefreshOff: true }); + expect(dotOf(po).classList.contains('is-paused')).toBe(true); + }); + + it('asks to keep the chains it showed while the filter stays on, and forgets them after', async () => { + load.mockResolvedValue(withCounts(1, [runningSequence()])); + const po = await mount(); + const watchedId = runningSequence().chainId; + po.chip('inflight').click(); + await flushPromises(); + expect(load).toHaveBeenLastCalledWith({ + request: expect.objectContaining({ + statusFilter: 'inflight', + keepChainIds: [watchedId] + }) + }); + po.chip('all').click(); + await flushPromises(); + expect(load).toHaveBeenLastCalledWith({ + request: expect.objectContaining({ statusFilter: 'all', keepChainIds: [] }) + }); + }); + }); + + describe('compact layout controls', () => { + it('offers a shorter range menu that changes the same range', async () => { + load.mockResolvedValue(page([completedSequence()])); + const po = await mount(); + const compact = po.byId('range-compact'); + expect(compact.label).toBe('7 days'); + compact.dispatchEvent(new CustomEvent('select', { detail: { value: '30' } })); + await flushPromises(); + expect(load).toHaveBeenLastCalledWith({ + request: expect.objectContaining({ daysBack: 30 }) + }); + }); + + it('offers view all results as an icon', async () => { + load.mockResolvedValue(page([completedSequence()])); + const po = await mount(); + const compact = po.byId('view-all-compact'); + expect(compact.title).toBe('View all results'); + compact.click(); + expect(mockNavigate).toHaveBeenCalled(); + }); + }); + + describe('footer', () => { + it('puts the library on the left and the maker on the right', async () => { + load.mockResolvedValue(page([completedSequence()])); + const po = await mount(); + const foot = po.root.querySelector('.foot'); + const parts = [...foot.children]; + expect(parts[0].textContent).toContain(`Async Lib`); + expect(parts[0].textContent).toContain(`v${VERSION}`); + expect(parts[parts.length - 1].textContent).toContain('Beyond The Cloud'); + expect(parts[parts.length - 1].textContent).not.toContain('Async Lib'); + }); + + it('keeps the older count out of the three columns', async () => { + load.mockResolvedValue(page([completedSequence()], { olderChainCount: 3 })); + const po = await mount(); + expect(po.byId('older-count').closest('.foot-older')).not.toBeNull(); + }); + }); + + describe('switching lists', () => { + const LINE_DELAY_MS = 300; + const deferred = () => { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + }; + const ids = po => po.sequences.map(card => card.sequence.chainId); + const everything = () => + page([failedSequence(), completedSequence('done')], { + counts: { all: 2, inflight: 0, failed: 1, completed: 1 }, + totalChainCount: 2 + }); + const tooManyToHold = () => + page([failedSequence(), completedSequence('done')], { + counts: { all: 80, inflight: 0, failed: 1, completed: 79 }, + totalChainCount: 80, + hasMore: true + }); + const openFilter = async (po, name) => { + po.chip(name).click(); + await flushPromises(); + }; + const line = po => po.byId('load-line'); + const passLineDelay = async () => { + jest.advanceTimersByTime(LINE_DELAY_MS); + await flushPromises(); + }; + + it('filters what it holds at once, before the server answers', async () => { + load.mockResolvedValueOnce(everything()); + const po = await mount(); + const answer = deferred(); + load.mockReturnValueOnce(answer.promise); + await openFilter(po, 'failed'); + expect(ids(po)).toEqual(['chain-failed']); + answer.resolve(page([failedSequence()], { counts: everything().counts })); + await flushPromises(); + }); + + it('does not bring a finished chain back to life when switching lists', async () => { + const watched = { ...runningSequence(), chainId: 'watched' }; + const finished = { ...completedSequence('done'), chainId: 'watched' }; + load.mockResolvedValueOnce( + page([watched], { + counts: { all: 1, inflight: 1, failed: 0, completed: 0 }, + totalChainCount: 1 + }) + ); + const po = await mount(); + load.mockResolvedValueOnce( + page([finished], { + counts: { all: 1, inflight: 0, failed: 0, completed: 1 }, + totalChainCount: 1 + }) + ); + await openFilter(po, 'inflight'); + const answer = deferred(); + load.mockReturnValueOnce(answer.promise); + await openFilter(po, 'all'); + expect(po.chip('inflight').textContent).toContain('0'); + expect(po.chip('inflight').classList.contains('chip-live')).toBe(false); + answer.resolve(page([finished], { totalChainCount: 1 })); + await flushPromises(); + }); + + it('shows a chain first seen under another filter at once on the others', async () => { + const arrived = { ...runningSequence(), chainId: 'arrived' }; + load.mockResolvedValueOnce( + page([completedSequence('done')], { + counts: { all: 1, inflight: 0, failed: 0, completed: 1 }, + totalChainCount: 1 + }) + ); + const po = await mount(); + load.mockResolvedValueOnce( + page([arrived], { + counts: { all: 2, inflight: 1, failed: 0, completed: 1 }, + totalChainCount: 1 + }) + ); + await openFilter(po, 'inflight'); + const answer = deferred(); + load.mockReturnValueOnce(answer.promise); + await openFilter(po, 'all'); + expect(ids(po)).toEqual(['arrived', 'done']); + answer.resolve(page([arrived, completedSequence('done')], { totalChainCount: 2 })); + await flushPromises(); + }); + + it('updates the view in place when the server knows more', async () => { + load.mockResolvedValueOnce(everything()); + const po = await mount(); + const answer = deferred(); + load.mockReturnValueOnce(answer.promise); + await openFilter(po, 'failed'); + const shown = po.sequences[0]; + answer.resolve( + page([{ ...failedSequence(), chainId: 'late-failure' }, failedSequence()], { + counts: { all: 3, inflight: 0, failed: 2, completed: 1 }, + totalChainCount: 2 + }) + ); + await flushPromises(); + expect(ids(po)).toEqual(['late-failure', 'chain-failed']); + expect(po.sequences[1]).toBe(shown); + expect(po.chip('failed').textContent).toContain('2'); + }); + + it('shows no line while the list is already right', async () => { + load.mockResolvedValueOnce(everything()); + const po = await mount(); + const answer = deferred(); + load.mockReturnValueOnce(answer.promise); + await openFilter(po, 'failed'); + await passLineDelay(); + expect(line(po)).toBeNull(); + answer.resolve(page([failedSequence()])); + await flushPromises(); + }); + + it('does not flash a chain when the refresh changes nothing', async () => { + load.mockResolvedValue(everything()); + const po = await mount(); + await openFilter(po, 'failed'); + await openFilter(po, 'all'); + po.sequences.forEach(card => { + expect(card.highlight).toEqual({ added: [], changed: [] }); + }); + }); + + it('keeps the list and says so when the refresh fails', async () => { + load.mockResolvedValueOnce(everything()); + const po = await mount(); + load.mockRejectedValueOnce(new Error('503')); + await openFilter(po, 'failed'); + expect(po.byId('error')).toBeNull(); + expect(ids(po)).toEqual(['chain-failed']); + expect(po.liveText).toContain('Could not refresh'); + }); + + describe('with more chains than it holds', () => { + it('keeps the rows until the answer and shows the line only after the delay', async () => { + load.mockResolvedValueOnce(tooManyToHold()); + const po = await mount(); + const answer = deferred(); + load.mockReturnValueOnce(answer.promise); + await openFilter(po, 'failed'); + expect(ids(po)).toEqual(['chain-failed', 'done']); + expect(line(po)).toBeNull(); + await passLineDelay(); + expect(line(po)).not.toBeNull(); + answer.resolve(page([failedSequence()], { totalChainCount: 1 })); + await flushPromises(); + expect(ids(po)).toEqual(['chain-failed']); + expect(line(po)).toBeNull(); + }); + + it('never shows the line for a quick answer', async () => { + load.mockResolvedValue(tooManyToHold()); + const po = await mount(); + await openFilter(po, 'failed'); + await passLineDelay(); + expect(line(po)).toBeNull(); + }); + + it('holds the rows and shows the line when the pager crosses into the next block', async () => { + const chains = Array.from({ length: 50 }, (_, index) => + completedSequence(`c${index}`) + ); + load.mockResolvedValueOnce(page(chains, { totalChainCount: 80, hasMore: true })); + const po = await mount(); + const lastPageOfBlock = 7; + for (let i = 0; i < lastPageOfBlock; i++) po.byId('next').click(); + await flushPromises(); + const before = ids(po); + const answer = deferred(); + load.mockReturnValueOnce(answer.promise); + po.byId('next').click(); + await flushPromises(); + expect(ids(po)).toEqual(before); + await passLineDelay(); + expect(line(po)).not.toBeNull(); + answer.resolve( + page([completedSequence('c50')], { totalChainCount: 80, hasMore: false }) + ); + await flushPromises(); + expect(line(po)).toBeNull(); + expect(ids(po)).not.toEqual(before); + }); + + it('shows the error when there is no right list to keep', async () => { + load.mockResolvedValueOnce(tooManyToHold()); + const po = await mount(); + load.mockRejectedValueOnce(new Error('503')); + await openFilter(po, 'failed'); + expect(po.byId('error')).not.toBeNull(); + expect(line(po)).toBeNull(); + }); + }); + + it('never shows the line for a background refresh', async () => { + load.mockResolvedValueOnce(tooManyToHold()); + const po = await mount(); + const answer = deferred(); + load.mockReturnValueOnce(answer.promise); + po.byId('refresh').click(); + await flushPromises(); + await passLineDelay(); + expect(line(po)).toBeNull(); + answer.resolve(tooManyToHold()); + await flushPromises(); + }); + }); + + it('pages in memory through the prefetched block', async () => { + load.mockResolvedValue( + page([completedSequence('a'), completedSequence('b'), completedSequence('c')], { + totalChainCount: 3 + }) + ); + const po = await mount({ chainsPerPage: 1 }); + expect(po.sequences).toHaveLength(1); + expect(po.byId('pager').textContent).toContain('1-1 of 3'); + expect(po.byId('prev').disabled).toBe(true); + po.byId('next').click(); + await flushPromises(); + expect(load).toHaveBeenCalledTimes(1); + expect(po.sequences[0].sequence.chainId).toBe('b'); + expect(po.byId('pager').textContent).toContain('2-2 of 3'); + expect(po.byId('prev').disabled).toBe(false); + po.byId('next').click(); + await flushPromises(); + expect(po.byId('next').disabled).toBe(true); + }); + + it('fetches the next block when paging beyond the prefetched chains', async () => { + const block = Array.from({ length: 50 }, (_, i) => completedSequence('c' + i)); + load.mockResolvedValueOnce(page(block, { hasMore: true, totalChainCount: 60 })); + load.mockResolvedValueOnce( + page([completedSequence('c50')], { hasMore: false, totalChainCount: 60 }) + ); + const po = await mount({ chainsPerPage: 50 }); + po.byId('next').click(); + await flushPromises(); + expect(load).toHaveBeenLastCalledWith({ + request: expect.objectContaining({ chainOffset: 50 }) + }); + expect(po.sequences[0].sequence.chainId).toBe('c50'); + }); + + it('does not drop a pager click while a background refresh runs', async () => { + load.mockResolvedValue( + page([completedSequence('a'), completedSequence('b'), completedSequence('c')], { + totalChainCount: 3 + }) + ); + const po = await mount({ chainsPerPage: 1 }); + po.byId('last').click(); + await flushPromises(); + expect(po.byId('pager').textContent).toContain('3-3 of 3'); + let finishRefresh; + load.mockReturnValueOnce( + new Promise(resolve => { + finishRefresh = resolve; + }) + ); + po.byId('refresh').click(); + await flushPromises(); + po.byId('first').click(); + await flushPromises(); + expect(po.byId('pager').textContent).toContain('1-1 of 3'); + finishRefresh( + page([completedSequence('a'), completedSequence('b'), completedSequence('c')], { + totalChainCount: 3 + }) + ); + await flushPromises(); + expect(po.sequences[0].sequence.chainId).toBe('a'); + }); + + it('ignores the response of an older request that lands after a newer one', async () => { + const block = Array.from({ length: 50 }, (_, i) => completedSequence('c' + i)); + load.mockResolvedValueOnce(page(block, { hasMore: true, totalChainCount: 60 })); + const po = await mount({ chainsPerPage: 50 }); + let finishOlder; + let finishNewer; + load.mockReturnValueOnce( + new Promise(resolve => { + finishOlder = resolve; + }) + ); + load.mockReturnValueOnce( + new Promise(resolve => { + finishNewer = resolve; + }) + ); + po.byId('refresh').click(); + await flushPromises(); + po.byId('next').click(); + await flushPromises(); + finishNewer(page([completedSequence('c50')], { totalChainCount: 60 })); + await flushPromises(); + finishOlder(page(block, { hasMore: true, totalChainCount: 60 })); + await flushPromises(); + expect(po.sequences[0].sequence.chainId).toBe('c50'); + }); + + it('opens a replay in place when it is loaded, else navigates to its result', async () => { + const replay = completedSequence('chain-replay'); + load.mockResolvedValue(page([failedSequence(), replay])); + const po = await mount(); + po.sequences[1].expand = jest.fn(); + po.sequences[1].scrollTo = jest.fn(); + po.sequences[0].dispatchEvent( + new CustomEvent('openreplay', { + detail: { resultId: 'a0X000000000002AAA', chainId: 'chain-replay' } + }) + ); + await flushPromises(); + expect(po.sequences[1].expand).toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + po.sequences[0].dispatchEvent( + new CustomEvent('openreplay', { + detail: { resultId: 'a0X000000000003AAA', chainId: 'chain-elsewhere' } + }) + ); + expect(mockNavigate).toHaveBeenCalledWith({ + type: 'standard__recordPage', + attributes: { recordId: 'a0X000000000003AAA', actionName: 'view' } + }); + }); + + it('offers a range menu, defaults to 7 days and reloads from the first page on change', async () => { + load.mockResolvedValue(page([completedSequence()])); + const po = await mount({ daysBack: undefined }); + expect(load).toHaveBeenLastCalledWith({ + request: expect.objectContaining({ daysBack: 7 }) + }); + expect(po.byId('range').label).toBe('Last 7 days'); + po.byId('range').dispatchEvent(new CustomEvent('select', { detail: { value: '0' } })); + await flushPromises(); + expect(load).toHaveBeenLastCalledWith({ + request: expect.objectContaining({ daysBack: 0, chainOffset: 0 }) + }); + expect(po.byId('range').label).toBe('All time'); + }); + + it('holds a fixed height and fits the page size to it', async () => { + const many = Array.from({ length: 12 }, (_, i) => completedSequence('c' + i)); + load.mockResolvedValue(page(many, { totalChainCount: 12 })); + const po = await mount({ maxHeight: 400 }); + expect(po.byId('scroller').style.height).toBe('400px'); + expect(po.sequences).toHaveLength(4); + expect(po.byId('pager').textContent).toContain('1-4 of 12'); + const explicit = await mount({ maxHeight: 400, chainsPerPage: 3 }); + expect(explicit.sequences).toHaveLength(3); + const grow = await mount({ maxHeight: 0 }); + expect(grow.byId('scroller').style.height).toBe(''); + }); + + it('scrolls the list by the amount a toggled header moved, in the same pass', async () => { + const many = Array.from({ length: 12 }, (_, i) => completedSequence('c' + i)); + load.mockResolvedValue(page(many, { totalChainCount: 12 })); + const po = await mount({ maxHeight: 400 }); + const scroller = po.byId('scroller'); + Object.defineProperties(scroller, { + scrollHeight: { value: 1000, configurable: true }, + clientHeight: { value: 400, configurable: true }, + scrollTop: { value: 300, writable: true, configurable: true } + }); + const toggle = (card, delta, open) => + card.dispatchEvent( + new CustomEvent('sequencetoggle', { detail: { open, delta }, bubbles: false }) + ); + toggle(po.sequences[1], -200, false); + expect(scroller.scrollTop).toBe(100); + expect(po.byId('filler')).toBeNull(); + + toggle(po.sequences[1], 700, true); + await flushPromises(); + expect(po.byId('filler').style.height).toBe('200px'); + expect(scroller.scrollTop).toBe(800); + + Object.defineProperty(scroller, 'scrollHeight', { value: 1200, configurable: true }); + scroller.scrollTop = 500; + scroller.dispatchEvent(new CustomEvent('scroll')); + await flushPromises(); + expect(po.byId('filler')).toBeNull(); + }); + + it('keeps a filler that is still in view', async () => { + const many = Array.from({ length: 12 }, (_, i) => completedSequence('c' + i)); + load.mockResolvedValue(page(many, { totalChainCount: 12 })); + const po = await mount({ maxHeight: 400 }); + const scroller = po.byId('scroller'); + Object.defineProperties(scroller, { + scrollHeight: { value: 1000, configurable: true }, + clientHeight: { value: 400, configurable: true }, + scrollTop: { value: 600, writable: true, configurable: true } + }); + po.sequences[1].dispatchEvent( + new CustomEvent('sequencetoggle', { detail: { open: true, delta: 100 } }) + ); + await flushPromises(); + expect(po.byId('filler').style.height).toBe('100px'); + Object.defineProperty(scroller, 'scrollHeight', { value: 1100, configurable: true }); + scroller.scrollTop = 650; + scroller.dispatchEvent(new CustomEvent('scroll')); + await flushPromises(); + expect(po.byId('filler').style.height).toBe('100px'); + }); + + it('keeps the first in-flight chain open after it settles and only flags newcomers', async () => { + const running = runningSequence(); + const settled = runningSequence(); + settled.steps.forEach(row => { + row.status = 'COMPLETED'; + row.apexJobStatus = undefined; + }); + const newcomer = runningSequence(); + newcomer.chainId = 'chain-new'; + load.mockResolvedValueOnce(page([running])).mockResolvedValueOnce( + page([newcomer, settled], { fingerprint: 'fp-2' }) + ); + poll.mockResolvedValueOnce({ fingerprint: 'fp-2', apexJobStatusById: {} }); + const po = await mount(); + expect(po.sequences[0].defaultExpanded).toBe(true); + jest.advanceTimersByTime(POLL.running); + await flushPromises(); + await flushPromises(); + expect(po.sequences.map(card => card.sequence.chainId)).toEqual([ + 'chain-new', + 'chain-running' + ]); + expect(po.sequences[1].defaultExpanded).toBe(true); + expect(po.sequences[0].defaultExpanded).toBe(false); + expect(po.sequences[0].isNew).toBe(true); + expect(po.liveText).toContain('1 new sequence running'); + }); + + it('follows a newcomer when follow new runs is on', async () => { + const running = runningSequence(); + const newcomer = runningSequence(); + newcomer.chainId = 'chain-new'; + load.mockResolvedValueOnce(page([running])).mockResolvedValueOnce( + page([newcomer, running], { fingerprint: 'fp-2' }) + ); + poll.mockResolvedValueOnce({ fingerprint: 'fp-2', apexJobStatusById: {} }); + const po = await mount({ followNewRuns: true }); + expect(po.byId('follow').checked).toBe(true); + expect(po.byId('follow').type).toBe('toggle'); + jest.advanceTimersByTime(POLL.running); + await flushPromises(); + await flushPromises(); + expect(po.sequences[0].defaultExpanded).toBe(true); + expect(po.sequences[1].defaultExpanded).toBe(false); + expect(po.sequences[0].isNew).toBe(false); + po.byId('follow').dispatchEvent(new CustomEvent('change', { detail: { checked: false } })); + await flushPromises(); + expect(po.byId('follow').checked).toBe(false); + po.byId('follow-label').click(); + await flushPromises(); + expect(po.byId('follow').checked).toBe(true); + }); + + it('refreshes in place from the button without a spinner', async () => { + load.mockResolvedValueOnce(page([completedSequence()])); + load.mockReturnValueOnce(new Promise(() => {})); + const po = await mount(); + po.byId('refresh').click(); + await flushPromises(); + expect(po.byId('loading')).toBeNull(); + expect(po.sequences).toHaveLength(1); + expect(po.liveText).toContain('Refreshing'); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('flags a stale idle card after ten minutes without a check', async () => { + load.mockResolvedValue(page([completedSequence()])); + const po = await mount(); + expect(po.byId('refresh').variant).not.toBe('brand'); + jest.advanceTimersByTime(POLL.staleMinutes * 60 * 1000 + 31000); + await flushPromises(); + expect(po.byId('refresh').variant).toBe('brand'); + expect(po.liveText).toContain('Last checked'); + expect(po.liveText).toContain('min ago'); + }); + + it('checks right away when the tab becomes visible again', async () => { + load.mockResolvedValue(page([runningSequence()])); + await mount(); + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new CustomEvent('visibilitychange')); + await flushPromises(); + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + configurable: true + }); + document.dispatchEvent(new CustomEvent('visibilitychange')); + await flushPromises(); + expect(poll).toHaveBeenCalledTimes(1); + }); + + it('polls only while a chain is in flight and reloads when the fingerprint moves', async () => { + load.mockResolvedValue(page([runningSequence()])); + const po = await mount(); + expect(po.liveText).toBe('Live'); + expect(po.byId('live').getAttribute('title')).toContain('every 3 s'); + expect(po.sequences[0].pageRecordId).toBe('001000000000001AAA'); + poll.mockResolvedValueOnce({ fingerprint: 'fp-2', apexJobStatusById: {} }); + jest.advanceTimersByTime(POLL.running); + await flushPromises(); + expect(poll).toHaveBeenCalledWith({ + recordId: '001000000000001AAA', + chainIds: ['chain-running'] + }); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('does not poll when nothing is in flight', async () => { + load.mockResolvedValue(page([failedSequence(), completedSequence()])); + const po = await mount(); + jest.advanceTimersByTime(POLL.queued * 3); + await flushPromises(); + expect(poll).not.toHaveBeenCalled(); + expect(po.liveText).toBe('Idle'); + expect(po.byId('live').getAttribute('title')).toContain('Nothing in flight'); + }); + + it('names the clock time of the next check while every step waits for its retry', async () => { + const retrying = runningSequence(); + retrying.steps[1].apexJobStatus = null; + retrying.steps[1].retryAttempts = 1; + retrying.steps[1].nextAttemptAt = new Date(Date.now() + 4 * 60000).toISOString(); + retrying.steps = retrying.steps.slice(0, 2); + load.mockResolvedValue(page([retrying])); + const po = await mount(); + expect(po.liveText).toContain('Live · next check'); + const at = po.byId('live').querySelector('lightning-formatted-date-time'); + expect(Date.parse(at.value)).toBeGreaterThan(Date.now() + 3 * 60000); + }); + + it('pauses while the tab is hidden and hard-stops after the polling budget', async () => { + load.mockResolvedValue(page([runningSequence()])); + const po = await mount(); + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new CustomEvent('visibilitychange')); + await flushPromises(); + expect(po.liveText).toContain('Paused'); + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + configurable: true + }); + document.dispatchEvent(new CustomEvent('visibilitychange')); + await flushPromises(); + jest.advanceTimersByTime(POLL.maxMinutes * 60 * 1000 + POLL.running); + await flushPromises(); + expect(po.liveText).toContain('Paused'); + expect(po.byId('refresh')).not.toBeNull(); + }); + + it('keeps polling after one failed poll and clears the error on the next success', async () => { + load.mockResolvedValue(page([runningSequence()])); + const po = await mount(); + poll.mockRejectedValueOnce(new Error('503')); + jest.advanceTimersByTime(POLL.running); + await flushPromises(); + expect(po.byId('error').textContent).toContain('503'); + poll.mockResolvedValueOnce({ fingerprint: 'fp-1', apexJobStatusById: {} }); + jest.advanceTimersByTime(POLL.running); + await flushPromises(); + expect(poll).toHaveBeenCalledTimes(2); + expect(po.byId('error')).toBeNull(); + expect(po.liveText).toContain('Live'); + }); + + it('stops live refresh after three failed polls in a row', async () => { + load.mockResolvedValue(page([runningSequence()])); + const po = await mount(); + poll.mockRejectedValue(new Error('503')); + for (let i = 0; i < 4; i++) { + jest.advanceTimersByTime(POLL.running); + // eslint-disable-next-line no-await-in-loop + await flushPromises(); + } + expect(poll).toHaveBeenCalledTimes(3); + expect(po.liveText).toContain('Paused'); + expect(po.byId('refresh')).not.toBeNull(); + }); + + it('honours the live refresh switch', async () => { + load.mockResolvedValue(page([runningSequence()])); + const po = await mount({ liveRefreshOff: true }); + jest.advanceTimersByTime(POLL.running * 2); + await flushPromises(); + expect(poll).not.toHaveBeenCalled(); + expect(po.byId('live')).toBeNull(); + }); + + it('marks steps that appeared or changed since the previous load and announces them', async () => { + const before = runningSequence(); + const after = runningSequence(); + after.steps[1].apexJobStatus = undefined; + after.steps[1].status = 'COMPLETED'; + after.steps.splice( + 2, + 0, + step({ + chainSequence: 3, + customJobId: 'new-1', + jobName: 'Calculate tax', + status: 'ENQUEUED', + addedByCustomJobId: 'r2' + }) + ); + load.mockResolvedValueOnce(page([before])).mockResolvedValueOnce( + page([after], { fingerprint: 'fp-2' }) + ); + poll.mockResolvedValueOnce({ fingerprint: 'fp-2', apexJobStatusById: {} }); + const po = await mount(); + jest.advanceTimersByTime(POLL.running); + await flushPromises(); + await flushPromises(); + const highlight = po.sequences[0].highlight; + expect(highlight.added).toEqual(['new-1']); + expect(highlight.changed).toEqual(['r2']); + expect(po.byId('announcer').textContent).toContain('Calculate tax added'); + jest.advanceTimersByTime(10000); + await flushPromises(); + expect(po.sequences[0].highlight.added).toEqual([]); + }); + + it('hides requeue actions when the page says the viewer cannot requeue', async () => { + load.mockResolvedValue(page([failedSequence()], { canRequeue: false })); + const po = await mount(); + expect(po.sequences[0].canRequeue).toBe(false); + }); + + it('requeues on a child event, toasts the outcome and reloads', async () => { + load.mockResolvedValue(page([failedSequence()])); + requeue.mockResolvedValue({ + requeued: ['a1'], + skipReasonByResultId: { a2: 'No payload was stored' }, + droppedDependencyByResultId: {}, + chainId: 'chain-new' + }); + const po = await mount(); + const toasts = []; + po.el.addEventListener('lightning__showtoast', event => toasts.push(event.detail)); + po.sequences[0].dispatchEvent( + new CustomEvent('requeue', { + detail: { resultIds: ['a1', 'a2'], label: '2 failed steps of Order' } + }) + ); + await flushPromises(); + expect(LightningConfirm.open).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('2 failed steps of Order') }) + ); + expect(requeue).toHaveBeenCalledWith({ resultIds: ['a1', 'a2'] }); + expect(toasts[0].title).toContain('1 step requeued'); + expect(toasts[0].message).toContain('No payload was stored'); + expect(load).toHaveBeenCalledTimes(2); + jest.advanceTimersByTime(POLL.afterRequeue); + await flushPromises(); + expect(poll).toHaveBeenCalledTimes(1); + }); + + it('does nothing when the requeue confirmation is declined', async () => { + load.mockResolvedValue(page([failedSequence()])); + LightningConfirm.open.mockResolvedValueOnce(false); + const po = await mount(); + po.sequences[0].dispatchEvent( + new CustomEvent('requeue', { detail: { resultIds: ['a1'], label: 'x' } }) + ); + await flushPromises(); + expect(requeue).not.toHaveBeenCalled(); + }); + + it('opens a result record from a sequence menu', async () => { + load.mockResolvedValue(page([failedSequence()])); + const po = await mount(); + po.sequences[0].dispatchEvent( + new CustomEvent('openresult', { detail: { resultId: 'a0X000000000001AAA' } }) + ); + expect(mockNavigate).toHaveBeenCalledWith({ + type: 'standard__recordPage', + attributes: { recordId: 'a0X000000000001AAA', actionName: 'view' } + }); + }); + + it('opens the chain map with the sequence and a label that survives a missing name', async () => { + const unnamed = failedSequence(); + unnamed.name = null; + load.mockResolvedValue(page([unnamed])); + const po = await mount(); + po.sequences[0].dispatchEvent( + new CustomEvent('openmap', { detail: { chainId: 'chain-failed' } }) + ); + await flushPromises(); + expect(AsyncChainMap.open).toHaveBeenCalledWith( + expect.objectContaining({ sequence: unnamed, label: 'Chain map · Async jobs' }) + ); + }); + + it('opens the By chain list view from the header button', async () => { + load.mockResolvedValue(page([failedSequence()])); + const po = await mount(); + po.byId('view-all').click(); + expect(mockNavigate).toHaveBeenCalledWith({ + type: 'standard__objectPage', + attributes: { objectApiName: 'AsyncResult__c', actionName: 'list' }, + state: { filterName: 'ByChain' } + }); + }); + + it('toggles the legend from the help button', async () => { + load.mockResolvedValue(page([completedSequence()])); + const po = await mount(); + expect(po.byId('legend')).toBeNull(); + po.byId('help').click(); + await flushPromises(); + expect(po.byId('legend')).not.toBeNull(); + }); + + it('renders a sequence with no steps without crashing', async () => { + load.mockResolvedValue(page([sequence('empty-chain', [])])); + const po = await mount(); + expect(po.sequences).toHaveLength(1); + }); +}); diff --git a/force-app/main/default/lwc/asyncJobTimeline/__tests__/data/fixtures.js b/force-app/main/default/lwc/asyncJobTimeline/__tests__/data/fixtures.js new file mode 100644 index 0000000..d0a8cdd --- /dev/null +++ b/force-app/main/default/lwc/asyncJobTimeline/__tests__/data/fixtures.js @@ -0,0 +1,136 @@ +let counter = 0; + +export const step = overrides => { + counter += 1; + return { + id: 'a0' + String(counter).padStart(13, '0'), + name: 'AR-' + String(counter).padStart(5, '0'), + customJobId: 'job-' + counter, + className: 'StepJob', + jobName: 'Step ' + counter, + status: 'COMPLETED', + createdAt: '2026-11-02T09:00:00.000Z', + startedAt: '2026-11-02T09:00:01.000Z', + finishedAt: '2026-11-02T09:00:03.000Z', + records: [], + ...overrides + }; +}; + +export const sequence = (chainId, steps, overrides = {}) => ({ + chainId, + name: 'Order fulfilment pipeline', + startedAt: '2026-11-02T09:00:00.000Z', + steps, + ...overrides +}); + +export const page = (sequences, overrides = {}) => ({ + sequences, + hasMore: false, + olderChainCount: 0, + canRequeue: true, + fingerprint: 'fp-1', + loadedAt: '2026-11-02T09:05:00.000Z', + ...overrides +}); + +export const failedSequence = () => + sequence('chain-failed', [ + step({ chainSequence: 1, customJobId: 'f1', jobName: 'Reserve stock' }), + step({ chainSequence: 2, customJobId: 'f2', jobName: 'Create invoice', priority: 1 }), + step({ + chainSequence: 3, + customJobId: 'f3', + jobName: 'Commission split', + status: 'FAILED', + exceptionType: 'System.DmlException', + exceptionMessage: 'UNABLE_TO_LOCK_ROW: record is being modified', + retryAttempts: 2, + retryHistory: 'Attempt 1: ...\nAttempt 2: ...', + requeueStatus: 'Stored' + }), + step({ + chainSequence: 4, + customJobId: 'f4', + jobName: 'Account rollups', + status: 'SKIPPED_DEPENDENCY', + skipReason: "Dependency 'CommissionSplitJob' required SUCCESS but was FAILURE", + requeueStatus: 'Stored', + dependencies: JSON.stringify([ + { resolvedTargetCustomJobId: 'f3', requiredOutcome: 'SUCCESS' } + ]) + }), + step({ + chainSequence: 5, + customJobId: 'f5', + jobName: 'Sync to ERP', + status: 'SKIPPED_DEPENDENCY', + requeueStatus: null + }) + ]); + +export const runningSequence = () => + sequence( + 'chain-running', + [ + step({ + chainSequence: 1, + customJobId: 'r1', + jobName: 'Commission split', + records: [ + { + key: '001000000000001AAA', + name: 'Order #10248', + objectName: 'Account', + source: 'Salesforce' + } + ] + }), + step({ + chainSequence: 2, + customJobId: 'r2', + jobName: 'Account rollups', + status: 'ENQUEUED', + apexJobStatus: 'Processing', + salesforceJobId: '707000000000001AAA', + finishedAt: null + }), + step({ + chainSequence: 3, + customJobId: 'r3', + jobName: 'Notify sales', + status: 'ENQUEUED', + finishedAt: null + }) + ], + { name: 'Order fulfilment pipeline' } + ); + +export const completedSequence = (chainId = 'chain-done') => + sequence( + chainId, + [step({ chainSequence: 1 }), step({ chainSequence: 2 }), step({ chainSequence: 3 })], + { name: 'Nightly price refresh' } + ); + +export const longSequence = (count, failedAt) => + sequence( + 'chain-long', + Array.from({ length: count }, (_, i) => + step({ + chainSequence: i + 1, + customJobId: 'l' + (i + 1), + status: i + 1 === failedAt ? 'FAILED' : 'COMPLETED', + requeueStatus: 'Stored' + }) + ), + { name: 'Long run' } + ); + +export const flushPromises = async () => { + for (let turn = 0; turn < 10; turn++) { + // eslint-disable-next-line no-await-in-loop + await Promise.resolve(); + } +}; diff --git a/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.css b/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.css new file mode 100644 index 0000000..1fe4099 --- /dev/null +++ b/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.css @@ -0,0 +1,455 @@ +@import 'c/asyncBadgeStyles'; +:host { + --ok: #2e844a; + --bad: #ba0517; + --run: #0176d3; + --wait: #939393; + --delay: #8a5a00; + --muted: #747474; + --line: #e5e5e5; + display: block; + container-type: inline-size; +} +.logo { + width: 2rem; + height: 2rem; + border-radius: 0.25rem; + background: #032d60; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; +} +.logo img { + width: 1.5rem; + height: 1.5rem; +} +.title { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + white-space: nowrap; + font-size: 1rem; + font-weight: 700; +} +.live { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 400; + color: var(--muted); +} +.live-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ok); + animation: pulse 2s infinite; +} +.live-dot.idle, +.live-dot.stopped, +.live-dot.stale { + background: var(--wait); + animation: none; +} +.live-dot.stale { + background: #dd7a01; +} +.live.is-stale { + color: #8c4b02; +} +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(46, 132, 74, 0.45); + } + 70% { + box-shadow: 0 0 0 8px rgba(46, 132, 74, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(46, 132, 74, 0); + } +} +@media (prefers-reduced-motion: reduce) { + .live-dot { + animation: none; + } +} +.actions { + display: flex; + gap: 0.25rem; + align-items: center; +} +.status-line { + padding: 0.125rem 1rem 0.375rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} +.follow { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: var(--muted); + cursor: pointer; +} +.follow lightning-input { + --slds-c-checkbox-toggle-color-background-checked: var(--run); + margin: 0; +} +.filler { + flex: 0 0 auto; +} +.body { + position: relative; +} +.body.legend-open { + min-height: 18rem; +} +.legend { + position: absolute; + top: 0.375rem; + right: 0.5rem; + width: min(40rem, calc(100% - 1rem)); + max-height: calc(100% - 0.75rem); + overflow: auto; + z-index: 2; + box-sizing: border-box; + font-size: 0.75rem; + color: #444; + padding: 0.5rem 0.75rem 0.75rem; + background: #fff; + border: 1px solid var(--line); + border-radius: 0.25rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} +.legend-head { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 700; + margin-bottom: 0.375rem; +} +.legend-grid { + display: grid; + grid-template-columns: 8rem 1fr; + gap: 0.375rem 0.75rem; + align-items: center; +} +.sample { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 3px; +} +.glyph { + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 700; + font-style: normal; + color: #fff; + box-sizing: border-box; +} +.glyph-ok { + background: var(--ok); +} +.glyph-bad { + background: #ba0517; +} +.glyph-skip { + background: #dd7a01; +} +.glyph-wait { + background: #fff; + border: 2px solid var(--wait); + color: var(--wait); +} +.glyph-delay { + background: #fff; + border: 2px solid var(--delay); + color: var(--delay); +} +.glyph-run { + background: #fff; + border: 2px solid var(--run); +} +.glyph-run::after { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + border: 2px solid var(--run); + border-right-color: transparent; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .glyph-run::after { + animation: none; + } +} +.tag { + font-size: 0.6875rem; + font-style: normal; + color: #444; + background: #f3f3f3; + border: 1px solid var(--line); + border-radius: 0.25rem; + padding: 0 0.375rem; + line-height: 1.3rem; + white-space: nowrap; +} +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + border: 1.5px solid var(--wait); + box-sizing: border-box; +} +.dot-ok { + background: var(--ok); + border-color: var(--ok); +} +.dot-bad { + background: #ba0517; + border-color: #ba0517; +} +.sq { + width: 7px; + height: 7px; + border-radius: 1px; + background: var(--line); + display: inline-block; +} +.sq-ok { + background: var(--ok); +} +.sq-bad { + background: #ba0517; +} +.outline { + display: inline-block; + width: 2.5rem; + height: 0.875rem; + border-radius: 0.25rem; + outline: 2px solid var(--run); + outline-offset: 2px; +} +.summary { + position: relative; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--line); + background: #fafafa; +} +.load-line { + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 2px; + overflow: hidden; + background: #b9d6f2; +} +.load-line::after { + content: ''; + display: block; + width: 30%; + height: 100%; + background: var(--run); + animation: load-line-slide 1s ease-in-out infinite; +} +@keyframes load-line-slide { + from { + transform: translateX(-20%); + } + to { + transform: translateX(334%); + } +} +@media (prefers-reduced-motion: reduce) { + .load-line::after { + width: 100%; + animation: none; + } +} +.chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0.625rem; + border-radius: 999px; + border: 1px solid var(--line); + background: #fff; + font-size: 0.75rem; + cursor: pointer; +} +.chip-live { + border-color: var(--run); + color: var(--run); + background: #eef4ff; + font-weight: 600; +} +.chip.active { + border-color: var(--run); + color: #fff; + background: var(--run); +} +.chip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + animation: chip-pulse 1.6s infinite; +} +.chip-dot.is-paused { + animation: none; +} +@keyframes chip-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(1, 118, 211, 0.5); + } + 70% { + box-shadow: 0 0 0 6px rgba(1, 118, 211, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(1, 118, 211, 0); + } +} +@media (prefers-reduced-motion: reduce) { + .chip-dot { + animation: none; + } +} +.chip b { + font-variant-numeric: tabular-nums; +} +.chip:focus-visible { + outline: 2px solid var(--run); + outline-offset: 1px; +} +.scroller { + overflow-anchor: none; +} +.loading { + position: relative; + min-height: 4rem; +} +.error { + padding: 0.75rem 1rem; + color: #ba0517; + font-size: 0.8125rem; +} +.empty { + padding: 1rem; + color: var(--muted); + font-size: 0.8125rem; +} +lightning-card { + --slds-c-card-footer-spacing-block-start: 0.25rem; + --slds-c-card-footer-spacing-block-end: 0.25rem; +} +.foot { + width: 100%; + box-sizing: border-box; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 0.5rem; + min-height: 2.25rem; + text-align: left; + line-height: 1; + font-size: 0.75rem; + color: var(--muted); +} +.foot-center { + display: inline-flex; + justify-content: center; +} +.foot-older { + grid-column: 1 / -1; + text-align: center; +} +.brand-library { + justify-self: start; +} +.brand-maker { + justify-self: end; +} +.brand { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.6875rem; + color: #a0a0a0; + white-space: nowrap; +} +.brand a { + color: inherit; + text-decoration: none; +} +.brand a:hover { + color: var(--run); + text-decoration: underline; +} +.pager { + display: inline-flex; + align-items: center; + gap: 0.125rem; + font-variant-numeric: tabular-nums; +} +.pager-label { + padding: 0 0.5rem; +} +.title-text { + overflow: hidden; + text-overflow: ellipsis; +} +.pager-label { + white-space: nowrap; +} +@container (min-width: 641px) { + .compact-only { + display: none; + } +} +@container (max-width: 640px) { + .full-only { + display: none; + } + .pager-label { + padding: 0 0.125rem; + } +} +@container (max-width: 400px) { + .brand-maker { + display: none; + } +} +@container (max-width: 390px) { + .title-text { + display: none; + } +} +@container (max-width: 340px) { + .brand-library { + display: none; + } +} diff --git a/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.html b/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.html new file mode 100644 index 0000000..23c3747 --- /dev/null +++ b/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.html @@ -0,0 +1,371 @@ + diff --git a/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.js b/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.js new file mode 100644 index 0000000..551762d --- /dev/null +++ b/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.js @@ -0,0 +1,849 @@ +import { LightningElement, api } from 'lwc'; +import { NavigationMixin } from 'lightning/navigation'; +import { ShowToastEvent } from 'lightning/platformShowToastEvent'; +import LightningConfirm from 'lightning/confirm'; +import load from '@salesforce/apex/AsyncJobTimelineController.load'; +import poll from '@salesforce/apex/AsyncJobTimelineController.poll'; +import requeue from '@salesforce/apex/AsyncJobTimelineController.requeue'; +import ASYNC_RESULT from '@salesforce/schema/AsyncResult__c'; +import LOGO from '@salesforce/resourceUrl/AsyncLibLogo'; +import AsyncChainMap from 'c/asyncChainMap'; +import { + POLL, + RANGES, + VERSION, + groupSteps, + sequenceSummary, + diffRows, + pollDelayFor +} from 'c/asyncJobTimelineModel'; + +const HIGHLIGHT_MS = 10000; +const LOAD_LINE_DELAY_MS = 300; +const CLOCK_MS = 30000; +const CARD_CHROME_PX = 96; +const COLLAPSED_ROW_PX = 66; +const DEFAULT_DAYS = 7; +const PREFETCH_CHAINS = 50; +const NAMESPACE_PREFIX = ASYNC_RESULT.objectApiName.replace(/AsyncResult__c$/, ''); +const IN_FLIGHT = new Set(['running', 'queued', 'delayed']); +const FILTERS = { + all: () => true, + inflight: summary => IN_FLIGHT.has(summary.status), + failed: summary => summary.status === 'failed' || summary.status === 'stopped', + completed: summary => summary.status === 'completed' +}; + +export default class AsyncJobTimeline extends NavigationMixin(LightningElement) { + @api recordId; + @api cardTitle = 'Async Jobs'; + @api daysBack = DEFAULT_DAYS; + @api chainsPerPage = 0; + @api maxHeight = 600; + @api liveRefreshOff = false; + @api followNewRuns = false; + + page; + loading = true; + busy = false; + refreshing = false; + error; + openChainIds = new Set(); + autoOpenConsidered = new Set(); + knownInFlight = new Set(); + freshChainIds = new Set(); + collapseToken = 0; + followOverride = null; + fillerPx = 0; + pendingScrollTop; + filter = 'all'; + pageIndex = 0; + blockIndex = 0; + rangeOverride = null; + showLegend = false; + announcement = ''; + highlightByChain = {}; + paused = false; + watchedChainIds = new Set(); + allChainsBlock = null; + heldSequences = null; + showLoadLine = false; + servedFromMemory = false; + refreshFailed = false; + stopped = false; + stoppedReason; + loadFailed = false; + pollFailures = 0; + pollTimer; + highlightTimer; + clockTimer; + pollStartedAt; + latestRequestId = 0; + replayGraceUntil = 0; + lastCheckedAt; + now = Date.now(); + previousRowsByChain; + logoUrl = LOGO; + versionLabel = `v${VERSION}`; + docsUrl = 'https://async.beyondthecloud.dev'; + makerUrl = 'https://beyondthecloud.dev'; + brandTitle = 'Built by Beyond The Cloud. Need help with async Apex? Talk to us.'; + + connectedCallback() { + document.addEventListener('visibilitychange', this.handleVisibility); + this.pollStartedAt = Date.now(); + // eslint-disable-next-line @lwc/lwc/no-async-operation + this.clockTimer = setInterval(() => { + this.now = Date.now(); + }, CLOCK_MS); + this.reload(); + } + + renderedCallback() { + if (this.focusLegendClose) { + this.focusLegendClose = false; + this.refs.legendClose?.focus(); + } + if (this.pendingScrollTop === undefined) return; + this.refs.scroller.scrollTop = this.pendingScrollTop; + this.pendingScrollTop = undefined; + } + + disconnectedCallback() { + this.closeLegend(); + document.removeEventListener('visibilitychange', this.handleVisibility); + clearTimeout(this.pollTimer); + clearTimeout(this.highlightTimer); + clearTimeout(this.loadLineTimer); + clearInterval(this.clockTimer); + } + + get days() { + if (this.rangeOverride !== null) return this.rangeOverride; + const configured = Number(this.daysBack); + const blank = this.daysBack === undefined || this.daysBack === null || this.daysBack === ''; + return blank || !Number.isFinite(configured) ? DEFAULT_DAYS : configured; + } + + get pageSize() { + const configured = Number(this.chainsPerPage); + if (Number.isFinite(configured) && configured > 0) return configured; + const height = Number(this.maxHeight) || 600; + return Math.max(3, Math.floor((height - CARD_CHROME_PX) / COLLAPSED_ROW_PX)); + } + + get isFollowing() { + return this.followOverride ?? Boolean(this.followNewRuns); + } + + get followTitle() { + return this.isFollowing + ? 'Following: a new chain in flight opens by itself and the others collapse.' + : 'Not following: a new chain in flight is only flagged.'; + } + + get rowsByChain() { + return (this.page?.sequences || []).map(sequence => ({ + sequence, + rows: groupSteps(sequence.steps) + })); + } + + get summaries() { + return this.rowsByChain.map(({ sequence, rows }) => ({ + sequence, + summary: sequenceSummary(rows) + })); + } + + get localOffset() { + return this.pageIndex * this.pageSize - this.blockIndex * PREFETCH_CHAINS; + } + + get visibleSequences() { + if (this.heldSequences) return this.heldSequences; + return (this.page?.sequences || []).slice( + this.localOffset, + this.localOffset + this.pageSize + ); + } + + get sequenceViews() { + return this.visibleSequences.map(sequence => ({ + key: sequence.chainId, + sequence, + defaultExpanded: this.openChainIds.has(sequence.chainId), + isNew: this.freshChainIds.has(sequence.chainId), + highlight: this.highlightByChain[sequence.chainId] || { added: [], changed: [] } + })); + } + + get fillerStyle() { + return this.fillerPx > 0 ? `height:${this.fillerPx}px` : ''; + } + + get chips() { + const counts = { + all: 0, + inflight: 0, + failed: 0, + completed: 0, + ...(this.page?.counts || {}) + }; + if (!this.page?.counts) { + this.summaries.forEach(({ summary }) => { + Object.keys(counts).forEach(name => { + if (FILTERS[name](summary)) counts[name] += 1; + }); + }); + } + const pulsing = + !this.liveRefreshOff && !this.loadFailed && !this.paused && !this.stoppedReason; + return [ + { name: 'all', label: 'All' }, + { name: 'inflight', label: 'In flight' }, + { name: 'failed', label: 'Failed' }, + { name: 'completed', label: 'Completed' } + ].map(chip => ({ + ...chip, + id: 'chip-' + chip.name, + count: counts[chip.name], + live: chip.name === 'inflight' && counts.inflight > 0, + dotClass: pulsing ? 'chip-dot' : 'chip-dot is-paused', + className: { + chip: true, + active: this.filter === chip.name, + 'chip-live': chip.name === 'inflight' && counts.inflight > 0 + } + })); + } + + get rangeOptions() { + return RANGES.map(range => ({ + value: String(range.days), + label: range.label, + checked: range.days === this.days + })); + } + + get rangeShortLabel() { + return this.rangeLabel.replace(/^Last /, ''); + } + + get rangeLabel() { + const known = RANGES.find(range => range.days === this.days); + return known ? known.label : `Last ${this.days} days`; + } + + get rangeText() { + return this.rangeLabel.charAt(0).toLowerCase() + this.rangeLabel.slice(1); + } + + get scrollerStyle() { + const height = Number(this.maxHeight); + return height > 0 ? `height:${height}px;overflow-y:auto` : ''; + } + + get hasSequences() { + return (this.page?.sequences || []).length > 0 || this.filter !== 'all'; + } + + get showEmpty() { + return !this.loading && !this.error && !this.hasSequences; + } + + get emptyText() { + const what = this.filter === 'all' ? 'async jobs' : `${this.filter} sequences`; + return this.days === 0 + ? `No ${what} for this record.` + : `No ${what} for this record in the ${this.rangeText}.`; + } + + get olderLabel() { + const count = this.page?.olderChainCount || 0; + if (count === 0 || this.days === 0) return null; + return `${count} older sequence${count === 1 ? '' : 's'} before the ${this.rangeText}`; + } + + get totalChains() { + return this.page?.totalChainCount || (this.page?.sequences || []).length; + } + + get showPager() { + return this.totalChains > this.pageSize; + } + + get pagerLabel() { + const from = this.pageIndex * this.pageSize + 1; + const to = this.pageIndex * this.pageSize + this.visibleSequences.length; + return `${from}-${to} of ${this.totalChains}`; + } + + get isFirstPage() { + return this.pageIndex === 0; + } + + get isLastPage() { + return (this.pageIndex + 1) * this.pageSize >= this.totalChains; + } + + get canRequeue() { + return Boolean(this.page?.canRequeue); + } + + get inFlight() { + return this.summaries.some(({ summary }) => IN_FLIGHT.has(summary.status)); + } + + get isStale() { + if (this.inFlight || !this.lastCheckedAt) return false; + return this.now - this.lastCheckedAt > POLL.staleMinutes * 60 * 1000; + } + + get refreshVariant() { + return this.isStale || this.stopped ? 'brand' : 'border'; + } + + get refreshTitle() { + return this.isStale ? 'Not checked for a while · refresh now' : 'Refresh now'; + } + + get liveState() { + if (this.refreshFailed) + return { + className: 'live-dot stale', + text: 'Could not refresh · showing what was loaded' + }; + if (this.refreshing) return { className: 'live-dot', text: 'Refreshing…' }; + if (this.stopped) + return { + className: 'live-dot stopped', + text: `Paused after ${this.stoppedReason} · refresh to continue` + }; + if (this.paused) + return { className: 'live-dot stopped', text: 'Paused while this tab is hidden' }; + if (this.isStale) { + const minutes = Math.floor((this.now - this.lastCheckedAt) / 60000); + return { + className: 'live-dot stale', + text: `Last checked ${minutes} min ago · refresh to see newer jobs` + }; + } + if (!this.inFlight && this.inReplayGrace) + return { + className: 'live-dot', + text: 'Live · waiting for the replay', + title: 'A replay without progress tracking has no rows until it settles' + }; + if (!this.inFlight) + return { + className: 'live-dot idle', + text: 'Idle', + title: 'Nothing in flight. The card refreshes when you come back to it or press refresh' + }; + const live = { + className: 'live-dot', + text: 'Live', + title: `Checks every ${POLL.running / 1000} s while a step runs, every ${POLL.queued / 1000} s while it waits in the queue` + }; + const fresh = this.freshChainIds.size; + if (fresh > 0) + return { + ...live, + text: `Live · ${fresh} new sequence${fresh === 1 ? '' : 's'} running` + }; + const delay = this.currentPollDelay; + if (delay > POLL.queued) + return { + className: 'live-dot', + text: 'Live · next check', + at: new Date(Date.now() + delay).toISOString(), + title: 'Nothing can change before the delay or retry is due' + }; + return live; + } + + get liveClass() { + return this.isStale || this.stopped ? 'live is-stale' : 'live'; + } + + get currentPollDelay() { + const rows = this.rowsByChain.flatMap(({ rows: chainRows }) => chainRows); + return pollDelayFor(rows, Date.now()) || POLL.queued; + } + + get showLive() { + return !this.liveRefreshOff && Boolean(this.page); + } + + get legendLabel() { + return this.showLegend ? 'Hide legend' : 'Legend'; + } + + get helpVariant() { + return this.showLegend ? 'brand' : 'border'; + } + + get bodyClass() { + return this.showLegend ? 'body legend-open' : 'body'; + } + + get inReplayGrace() { + return Date.now() < this.replayGraceUntil; + } + + handleVisibility = () => this.onVisibilityChange(); + + closeLegendOnEscape = event => { + if (event.key === 'Escape') this.closeLegend(); + }; + + async reload({ silent = false } = {}) { + this.latestRequestId += 1; + const requestId = this.latestRequestId; + if (!silent) this.loading = true; + this.busy = true; + try { + const result = await load({ + request: { + recordId: this.recordId, + daysBack: this.days, + chainLimit: PREFETCH_CHAINS, + chainOffset: this.blockIndex * PREFETCH_CHAINS, + statusFilter: this.filter, + keepChainIds: [...this.watchedChainIds] + } + }); + if (requestId !== this.latestRequestId) return; + this.error = undefined; + this.loadFailed = false; + this.refreshFailed = false; + this.lastCheckedAt = Date.now(); + this.applyPage(result); + } catch (failure) { + if (requestId !== this.latestRequestId) return; + if (this.servedFromMemory) this.refreshFailed = true; + else this.error = failure?.body?.message || failure?.message || String(failure); + this.loadFailed = true; + } + this.servedFromMemory = false; + this.releaseList(); + this.loading = false; + this.busy = false; + this.refreshing = false; + this.schedulePoll(); + } + + handleFollowChange(event) { + this.followOverride = event.detail.checked; + } + + toggleFollow() { + this.followOverride = !this.isFollowing; + } + + trackInFlight(sequences) { + const firstLook = !this.page; + const inFlight = sequences.filter(sequence => + IN_FLIGHT.has(sequenceSummary(groupSteps(sequence.steps)).status) + ); + const newcomers = inFlight.filter(sequence => !this.knownInFlight.has(sequence.chainId)); + inFlight.forEach(sequence => this.knownInFlight.add(sequence.chainId)); + if (this.isFollowing && !firstLook && newcomers.length) { + this.openChainIds = new Set([newcomers[0].chainId]); + this.collapseToken += 1; + return; + } + this.autoOpenFirstRunning(sequences); + if (firstLook) return; + newcomers + .filter(sequence => !this.openChainIds.has(sequence.chainId)) + .forEach(sequence => this.freshChainIds.add(sequence.chainId)); + this.freshChainIds = new Set(this.freshChainIds); + } + + autoOpenFirstRunning(sequences) { + if (this.filter !== 'all' || this.pageIndex !== 0) return; + const unconsidered = sequences + .slice(0, this.pageSize) + .filter(sequence => sequenceSummary(groupSteps(sequence.steps)).status === 'running') + .filter(sequence => !this.autoOpenConsidered.has(sequence.chainId)); + unconsidered.forEach(sequence => this.autoOpenConsidered.add(sequence.chainId)); + if (unconsidered.length && this.openChainIds.size === 0) + this.openChainIds = new Set([unconsidered[0].chainId]); + } + + rememberOpen(chainId, open) { + const opened = new Set(this.openChainIds); + if (open) opened.add(chainId); + else opened.delete(chainId); + this.openChainIds = opened; + } + + holdListUntilLoaded() { + this.heldSequences = this.visibleSequences; + clearTimeout(this.loadLineTimer); + // eslint-disable-next-line @lwc/lwc/no-async-operation + this.loadLineTimer = setTimeout(() => { + this.showLoadLine = true; + }, LOAD_LINE_DELAY_MS); + } + + releaseList() { + clearTimeout(this.loadLineTimer); + this.heldSequences = null; + this.showLoadLine = false; + } + + get holdsEveryChain() { + return Boolean(this.allChainsBlock) && !this.allChainsBlock.hasMore; + } + + showFilteredFromMemory() { + const sequences = this.allChainsBlock.sequences.filter(sequence => + FILTERS[this.filter](sequenceSummary(groupSteps(sequence.steps))) + ); + this.servedFromMemory = true; + this.page = { + ...this.allChainsBlock, + sequences, + totalChainCount: sequences.length + }; + if (this.filter === 'inflight') + sequences.forEach(sequence => this.watchedChainIds.add(sequence.chainId)); + } + + keepAllChainsFresh(result) { + if (this.filter === 'all' && this.blockIndex === 0) { + this.allChainsBlock = result; + return; + } + if (!this.allChainsBlock) return; + const fresherByChain = new Map( + (result.sequences || []).map(sequence => [sequence.chainId, sequence]) + ); + const knownChainIds = new Set( + this.allChainsBlock.sequences.map(sequence => sequence.chainId) + ); + const startedSinceTheBlockLoaded = (result.sequences || []).filter( + sequence => !knownChainIds.has(sequence.chainId) + ); + this.allChainsBlock = { + ...this.allChainsBlock, + counts: result.counts || this.allChainsBlock.counts, + sequences: [ + ...startedSinceTheBlockLoaded, + ...this.allChainsBlock.sequences.map( + sequence => fresherByChain.get(sequence.chainId) || sequence + ) + ] + }; + } + + applyPage(result) { + this.keepAllChainsFresh(result); + const rowsByChain = new Map(); + const highlightByChain = {}; + const announcements = []; + (result.sequences || []).forEach(sequence => { + const rows = groupSteps(sequence.steps); + rowsByChain.set(sequence.chainId, rows); + const diff = diffRows(this.previousRowsByChain?.get(sequence.chainId), rows); + highlightByChain[sequence.chainId] = { + added: [...diff.added], + changed: [...diff.changed] + }; + rows.forEach(row => { + const name = row.step.jobName || row.step.className; + if (diff.added.has(row.key)) announcements.push(`${name} added`); + else if (diff.changed.has(row.key)) + announcements.push(`${name} is now ${row.status.label}`); + }); + }); + this.previousRowsByChain = rowsByChain; + this.highlightByChain = highlightByChain; + this.trackInFlight(result.sequences || []); + if (this.filter === 'inflight') + (result.sequences || []).forEach(sequence => + this.watchedChainIds.add(sequence.chainId) + ); + this.page = result; + this.fillerPx = 0; + if (announcements.length) { + this.announcement = announcements.join('. '); + clearTimeout(this.highlightTimer); + // eslint-disable-next-line @lwc/lwc/no-async-operation + this.highlightTimer = setTimeout(() => { + this.highlightByChain = {}; + this.announcement = ''; + }, HIGHLIGHT_MS); + } + } + + schedulePoll(delayOverride) { + clearTimeout(this.pollTimer); + if (this.liveRefreshOff || !this.page || this.loadFailed || this.paused) return; + if (!this.inFlight && !this.inReplayGrace) return; + if (Date.now() - this.pollStartedAt > POLL.maxMinutes * 60 * 1000) { + this.stopPolling(`${POLL.maxMinutes} minutes`); + return; + } + this.stopped = false; + const delay = delayOverride ?? (this.inFlight ? this.currentPollDelay : POLL.queued); + // eslint-disable-next-line @lwc/lwc/no-async-operation + this.pollTimer = setTimeout(() => this.tick(), delay); + } + + async tick() { + try { + const result = await poll({ + recordId: this.recordId, + chainIds: (this.page?.sequences || []).map(sequence => sequence.chainId) + }); + this.lastCheckedAt = Date.now(); + if (result.fingerprint !== this.page.fingerprint) { + await this.reload({ silent: true }); + return; + } + this.applyApexJobStatuses(result.apexJobStatusById || {}); + this.error = undefined; + this.pollFailures = 0; + } catch (failure) { + this.error = failure?.body?.message || failure?.message || String(failure); + this.pollFailures += 1; + if (this.pollFailures >= POLL.maxFailures) { + this.stopPolling(`${POLL.maxFailures} failed checks`); + return; + } + } + this.schedulePoll(); + } + + stopPolling(reason) { + this.stopped = true; + this.stoppedReason = reason; + } + + applyApexJobStatuses(statusById) { + let changed = false; + const sequences = this.page.sequences.map(sequence => ({ + ...sequence, + steps: sequence.steps.map(step => { + const status = statusById[step.salesforceJobId]; + if (step.status !== 'ENQUEUED' || !status || status === step.apexJobStatus) + return step; + changed = true; + return { ...step, apexJobStatus: status }; + }) + })); + if (changed) this.page = { ...this.page, sequences }; + } + + onVisibilityChange() { + this.paused = document.visibilityState === 'hidden'; + clearTimeout(this.pollTimer); + if (this.paused) return; + this.now = Date.now(); + if ((this.inFlight || this.inReplayGrace) && !this.liveRefreshOff && !this.loadFailed) + this.tick(); + } + + refresh() { + this.stopped = false; + this.pollFailures = 0; + this.pollStartedAt = Date.now(); + this.refreshing = true; + this.reload({ silent: true }); + } + + handleSequenceToggle(event) { + const card = event.target; + this.rememberOpen(card.sequence?.chainId, event.detail?.open); + this.freshChainIds.delete(card.sequence?.chainId); + this.freshChainIds = new Set(this.freshChainIds); + const delta = event.detail?.delta || 0; + if (delta === 0) return; + const scroller = this.refs.scroller; + const target = scroller.scrollTop + delta; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + if (target <= maxScroll) { + scroller.scrollTop = target; + return; + } + this.fillerPx += target - maxScroll; + this.pendingScrollTop = target; + } + + handleScroll(event) { + if (this.fillerPx === 0) return; + const { scrollTop, clientHeight, scrollHeight } = event.target; + const fillerOutOfView = scrollTop + clientHeight <= scrollHeight - this.fillerPx; + if (fillerOutOfView) this.fillerPx = 0; + } + + nextPage() { + if (this.isLastPage) return; + this.goToPage(this.pageIndex + 1); + } + + firstPage() { + if (this.isFirstPage) return; + this.goToPage(0); + } + + lastPage() { + if (this.isLastPage) return; + this.goToPage(Math.ceil(this.totalChains / this.pageSize) - 1); + } + + previousPage() { + if (this.isFirstPage) return; + this.goToPage(this.pageIndex - 1); + } + + goToPage(index) { + const block = Math.floor((index * this.pageSize) / PREFETCH_CHAINS); + if (block !== this.blockIndex) this.holdListUntilLoaded(); + this.pageIndex = index; + if (block !== this.blockIndex) { + this.blockIndex = block; + this.reload({ silent: true }); + } + } + + selectRange(event) { + this.holdListUntilLoaded(); + this.rangeOverride = Number(event.detail.value); + this.allChainsBlock = null; + this.watchedChainIds = new Set(); + this.pageIndex = 0; + this.blockIndex = 0; + this.reload({ silent: true }); + } + + selectFilter(event) { + const filter = event.currentTarget.dataset.filter; + if (filter === this.filter) return; + if (!this.holdsEveryChain) this.holdListUntilLoaded(); + this.filter = filter; + this.watchedChainIds = new Set(); + this.pageIndex = 0; + this.blockIndex = 0; + if (this.holdsEveryChain) this.showFilteredFromMemory(); + this.reload({ silent: true }); + } + + toggleLegend() { + if (this.showLegend) { + this.closeLegend(); + return; + } + this.showLegend = true; + this.focusLegendClose = true; + document.addEventListener('keydown', this.closeLegendOnEscape); + } + + closeLegend() { + this.showLegend = false; + document.removeEventListener('keydown', this.closeLegendOnEscape); + } + + closeLegendOnClickBeside(event) { + if (this.showLegend && !this.refs.legend.contains(event.target)) this.closeLegend(); + } + + async handleRequeue(event) { + const { resultIds, label } = event.detail; + const confirmed = await LightningConfirm.open({ + label: 'Requeue', + message: `Requeue ${label}? The replay runs as a new sequence; the failed result stays as it is.`, + theme: 'warning', + variant: 'header' + }); + if (!confirmed) return; + try { + const outcome = await requeue({ resultIds }); + const count = outcome.requeued.length; + const skipped = Object.values(outcome.skipReasonByResultId || {}); + const dropped = Object.values(outcome.droppedDependencyByResultId || {}); + const notes = [...skipped, ...dropped]; + this.dispatchEvent( + new ShowToastEvent({ + title: `${count} step${count === 1 ? '' : 's'} requeued`, + message: notes.length + ? notes.join(' ') + : 'The replay runs as a new sequence at the top of this list.', + variant: skipped.length ? 'warning' : 'success', + mode: notes.length ? 'sticky' : 'dismissible' + }) + ); + } catch (failure) { + this.dispatchEvent( + new ShowToastEvent({ + title: 'Requeue failed', + message: failure?.body?.message || failure?.message || String(failure), + variant: 'error', + mode: 'sticky' + }) + ); + } + this.pollStartedAt = Date.now(); + this.replayGraceUntil = Date.now() + POLL.graceAfterRequeue; + this.pollFailures = 0; + this.stopped = false; + this.pageIndex = 0; + this.blockIndex = 0; + await this.reload({ silent: true }); + this.schedulePoll(POLL.afterRequeue); + } + + handleOpenReplay(event) { + const { resultId, chainId } = event.detail; + const loaded = (this.page?.sequences || []).findIndex( + sequence => sequence.chainId === chainId + ); + if (loaded < 0) { + this[NavigationMixin.Navigate]({ + type: 'standard__recordPage', + attributes: { recordId: resultId, actionName: 'view' } + }); + return; + } + this.pageIndex = Math.floor((this.blockIndex * PREFETCH_CHAINS + loaded) / this.pageSize); + Promise.resolve().then(() => { + const card = [...this.template.querySelectorAll('c-async-job-sequence')].find( + candidate => candidate.sequence?.chainId === chainId + ); + if (!card) return; + this.rememberOpen(chainId, true); + card.expand(); + card.scrollTo(); + }); + } + + handleOpenMap(event) { + const sequence = this.page.sequences.find( + candidate => candidate.chainId === event.detail.chainId + ); + AsyncChainMap.open({ + sequence, + size: 'large', + label: `Chain map · ${sequence.name || 'Async jobs'}` + }); + } + + handleOpenResult(event) { + this[NavigationMixin.Navigate]({ + type: 'standard__recordPage', + attributes: { recordId: event.detail.resultId, actionName: 'view' } + }); + } + + viewAllResults() { + this[NavigationMixin.Navigate]({ + type: 'standard__objectPage', + attributes: { objectApiName: ASYNC_RESULT.objectApiName, actionName: 'list' }, + state: { filterName: NAMESPACE_PREFIX + 'ByChain' } + }); + } +} diff --git a/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.js-meta.xml b/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.js-meta.xml new file mode 100644 index 0000000..7e550f4 --- /dev/null +++ b/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.js-meta.xml @@ -0,0 +1,89 @@ + + + 66.0 + true + Async Jobs + Every async job that ran, runs or will run for this record, with requeue for failed steps. + + lightning__RecordPage + lightningCommunity__Page + lightningCommunity__Default + + + + + + + + + + + + + + + + + + + + + diff --git a/force-app/main/default/lwc/asyncJobTimelineModel/__tests__/asyncJobTimelineModel.test.js b/force-app/main/default/lwc/asyncJobTimelineModel/__tests__/asyncJobTimelineModel.test.js new file mode 100644 index 0000000..248de23 --- /dev/null +++ b/force-app/main/default/lwc/asyncJobTimelineModel/__tests__/asyncJobTimelineModel.test.js @@ -0,0 +1,928 @@ +import { + CAPS, + currentRowKey, + POLL, + statusOf, + groupSteps, + sequenceSummary, + collapseCompletedRuns, + diffRows, + formatDuration, + relativeTime, + tagsFor, + failedPageGroups, + rowKeyOf, + pluralOf, + nextPollDelay, + pollDelayFor, + recordsOf, + RANGES, + pageStripFor, + delayOf, + truncate, + sameData +} from 'c/asyncJobTimelineModel'; + +const step = overrides => ({ + id: 'a0' + Math.random().toString(16).slice(2, 10), + customJobId: overrides.customJobId || 'job-' + overrides.chainSequence, + jobName: 'Step ' + overrides.chainSequence, + className: 'StepJob', + status: 'COMPLETED', + records: [], + ...overrides +}); + +describe('statusOf', () => { + it('maps outcomes to glyph keys and labels', () => { + expect(statusOf(step({ chainSequence: 1 }))).toEqual({ key: 'ok', label: 'Completed' }); + expect(statusOf(step({ chainSequence: 1, status: 'FAILED' }))).toEqual({ + key: 'bad', + label: 'Failed' + }); + expect(statusOf(step({ chainSequence: 1, status: 'SKIPPED_DEPENDENCY' }))).toEqual({ + key: 'skip', + label: 'Skipped · dependency' + }); + expect(statusOf(step({ chainSequence: 1, status: 'SKIPPED_CHAIN_STOPPED' })).label).toBe( + 'Skipped · chain stopped' + ); + expect(statusOf(step({ chainSequence: 1, status: 'FRAMEWORK_ERROR' })).key).toBe('bad'); + }); + + it('derives running, queued, retrying and aborted from the platform job', () => { + expect( + statusOf(step({ chainSequence: 1, status: 'ENQUEUED', apexJobStatus: 'Processing' })) + ).toEqual({ + key: 'run', + label: 'Running' + }); + expect( + statusOf(step({ chainSequence: 1, status: 'ENQUEUED', apexJobStatus: 'Queued' })).label + ).toBe('Queued'); + expect(statusOf(step({ chainSequence: 1, status: 'ENQUEUED' })).label).toBe('Enqueued'); + expect( + statusOf(step({ chainSequence: 1, status: 'ENQUEUED', apexJobStatus: 'Completed' })) + ).toEqual({ key: 'run', label: 'Finishing' }); + expect( + statusOf(step({ chainSequence: 1, status: 'ENQUEUED', apexJobStatus: 'Aborted' })).key + ).toBe('bad'); + expect( + statusOf( + step({ + chainSequence: 1, + status: 'ENQUEUED', + retryAttempts: 1, + nextAttemptAt: '2026-11-02T10:00:00Z' + }) + ) + ).toEqual({ key: 'retry', label: 'Retrying' }); + }); +}); + +describe('currentRowKey', () => { + const rows = statuses => + groupSteps( + statuses.map((status, i) => + step({ + chainSequence: i + 1, + customJobId: 'j' + (i + 1), + status: status === 'COMPLETED' ? 'COMPLETED' : 'ENQUEUED', + apexJobStatus: status === 'COMPLETED' ? null : status, + finishedAt: status === 'COMPLETED' ? '2026-11-02T10:00:01Z' : null + }) + ) + ); + + it('points at the running step, else the one the platform holds in its queue', () => { + expect(currentRowKey(rows(['COMPLETED', 'Processing', 'Queued', null]))).toBe('j2'); + expect(currentRowKey(rows(['COMPLETED', 'Completed', null]))).toBe('j2'); + expect(currentRowKey(rows(['COMPLETED', 'Queued', null]))).toBe('j2'); + expect(currentRowKey(rows(['COMPLETED', null, null]))).toBeNull(); + expect(currentRowKey(rows(['COMPLETED', 'COMPLETED']))).toBeNull(); + }); + + it('counts a scheduled retry as held by the platform', () => { + const retrying = groupSteps([ + step({ + chainSequence: 1, + customJobId: 'j1', + status: 'ENQUEUED', + retryAttempts: 1, + nextAttemptAt: '2026-11-02T10:00:00Z', + finishedAt: null + }), + step({ chainSequence: 2, customJobId: 'j2', status: 'ENQUEUED', finishedAt: null }) + ]); + expect(currentRowKey(retrying)).toBe('j1'); + }); +}); + +describe('groupSteps', () => { + const steps = [ + step({ chainSequence: 3, customJobId: 'c3' }), + step({ chainSequence: 1, customJobId: 'c1' }), + step({ chainSequence: 1, customJobId: 'fin', parentCustomJobId: 'c1', jobName: 'Audit' }), + step({ + chainSequence: 2, + customJobId: 'p1', + chunkRunId: 'p1', + chunkPage: 1, + chunkPages: 3 + }), + step({ + chainSequence: 2, + customJobId: 'p2', + chunkRunId: 'p1', + chunkPage: 2, + chunkPages: 3, + status: 'FAILED' + }), + step({ + chainSequence: 2, + customJobId: 'p3', + chunkRunId: 'p1', + chunkPage: 3, + chunkPages: 3, + status: 'ENQUEUED' + }) + ]; + + it('orders by chain sequence, nests finalizers and folds chunk pages into one row', () => { + const rows = groupSteps(steps); + expect(rows.map(row => row.step.customJobId)).toEqual(['c1', 'fin', 'p1', 'c3']); + expect(rows.map(row => row.kind)).toEqual(['job', 'finalizer', 'chunk', 'job']); + expect(rows[1].depth).toBe(1); + expect(rows[2].pages).toHaveLength(3); + expect(rows[2].status).toEqual({ key: 'wait', label: 'Enqueued' }); + }); + + it('reports a chunk run with failed pages as failed once every page settled', () => { + const settled = steps.map(s => { + return s.customJobId === 'p3' ? { ...s, status: 'COMPLETED' } : s; + }); + const rows = groupSteps(settled); + expect(rows[2].status).toEqual({ key: 'bad', label: '1 page failed' }); + }); +}); + +describe('sequenceSummary', () => { + it('counts rows, computes percent, status precedence and duration', () => { + const rows = groupSteps([ + step({ + chainSequence: 1, + startedAt: '2026-11-02T10:00:00.000Z', + finishedAt: '2026-11-02T10:00:02.000Z' + }), + step({ + chainSequence: 2, + status: 'FAILED', + startedAt: '2026-11-02T10:00:03.000Z', + finishedAt: '2026-11-02T10:00:04.500Z' + }), + step({ chainSequence: 3, status: 'SKIPPED_DEPENDENCY' }), + step({ chainSequence: 4, status: 'ENQUEUED', apexJobStatus: 'Queued' }) + ]); + const summary = sequenceSummary(rows); + expect(summary).toMatchObject({ + total: 4, + completed: 1, + failed: 1, + skipped: 1, + waiting: 1, + running: 0, + percent: 25 + }); + expect(summary.status).toBe('running'); + expect(summary.durationMs).toBe(4500); + }); + + it('prefers running over queued, and failed over completed when nothing is in flight', () => { + const running = sequenceSummary( + groupSteps([ + step({ chainSequence: 1, status: 'ENQUEUED', apexJobStatus: 'Processing' }), + step({ chainSequence: 2, status: 'ENQUEUED' }) + ]) + ); + expect(running.status).toBe('running'); + const failed = sequenceSummary( + groupSteps([step({ chainSequence: 1 }), step({ chainSequence: 2, status: 'FAILED' })]) + ); + expect(failed.status).toBe('failed'); + const done = sequenceSummary(groupSteps([step({ chainSequence: 1 })])); + expect(done.status).toBe('completed'); + expect(done.percent).toBe(100); + }); +}); + +describe('collapseCompletedRuns', () => { + const manySteps = (count, failedAt) => + groupSteps( + Array.from({ length: count }, (_, i) => + step({ chainSequence: i + 1, status: i + 1 === failedAt ? 'FAILED' : 'COMPLETED' }) + ) + ); + + it('leaves short sequences alone', () => { + expect(collapseCompletedRuns(manySteps(CAPS.stepsCollapsed))).toHaveLength( + CAPS.stepsCollapsed + ); + }); + + it('keeps the first 3, the last 2 and every non-completed row, folding the rest into gaps', () => { + const rows = collapseCompletedRuns(manySteps(30, 17)); + const kinds = rows.map(row => { + return row.kind === 'gap' ? 'gap:' + row.hiddenCount : row.step.chainSequence; + }); + expect(kinds).toEqual([1, 2, 3, 'gap:13', 17, 'gap:11', 29, 30]); + }); + + it('expands a gap back into its rows', () => { + const rows = collapseCompletedRuns(manySteps(30, 17)); + expect(rows[3].hiddenRows).toHaveLength(13); + expect(rows[3].hiddenRows[0].step.chainSequence).toBe(4); + }); +}); + +describe('diffRows', () => { + it('finds added and changed rows by custom job id', () => { + const before = groupSteps([ + step({ chainSequence: 1, customJobId: 'a' }), + step({ chainSequence: 2, customJobId: 'b', status: 'ENQUEUED' }) + ]); + const after = groupSteps([ + step({ chainSequence: 1, customJobId: 'a' }), + step({ chainSequence: 2, customJobId: 'x', addedByCustomJobId: 'a' }), + step({ + chainSequence: 3, + customJobId: 'b', + status: 'ENQUEUED', + apexJobStatus: 'Processing' + }) + ]); + const diff = diffRows(before, after); + expect([...diff.added]).toEqual(['x']); + expect([...diff.changed]).toEqual(['b']); + }); + + it('treats the first load as nothing new', () => { + const diff = diffRows(null, groupSteps([step({ chainSequence: 1 })])); + expect(diff.added.size).toBe(0); + }); +}); + +describe('formatDuration', () => { + it('keeps one decimal under a second and whole seconds above', () => { + expect(formatDuration(900)).toBe('0.9 s'); + expect(formatDuration(40)).toBe('<0.1 s'); + expect(formatDuration(1800)).toBe('2 s'); + expect(formatDuration(24400)).toBe('24 s'); + expect(formatDuration(72000)).toBe('1 min 12 s'); + expect(formatDuration(3720000)).toBe('1 h 2 min'); + expect(formatDuration(null)).toBeNull(); + expect(formatDuration(-5)).toBeNull(); + }); +}); + +describe('relativeTime', () => { + const now = Date.parse('2026-11-03T14:00:00.000Z'); + const at = iso => relativeTime(iso, now); + + it('counts minutes and hours back, then falls back to the date', () => { + expect(at('2026-11-03T13:59:40.000Z').text).toBe('just now'); + expect(at('2026-11-03T13:40:00.000Z').text).toBe('20 min ago'); + expect(at('2026-11-03T12:30:00.000Z').text).toBe('1 h 30 min ago'); + expect(at('2026-11-02T13:54:00.000Z').text).toMatch(/^Yesterday, /); + expect(at('2026-10-31T13:54:00.000Z').text).toMatch(/^[A-Z][a-z]{2}, /); + expect(at('2026-10-20T13:54:00.000Z').text).toMatch(/2026/); + expect(at('2026-10-20T13:54:00.000Z').full).toMatch(/2026/); + expect(relativeTime(null, now)).toBeNull(); + }); +}); + +describe('RANGES', () => { + it('offers a day, a week, a month and all time, with 0 meaning all', () => { + expect(RANGES.map(range => range.days)).toEqual([1, 7, 30, 0]); + expect(RANGES.find(range => range.days === 0).label).toBe('All time'); + }); +}); + +describe('recordsOf', () => { + it('unions the linked records of every chunk page without duplicates', () => { + const order = { + key: '001000000000001AAA', + name: 'Order', + objectName: 'Account', + source: 'Salesforce' + }; + const rows = groupSteps([ + step({ + chainSequence: 1, + customJobId: 'p1', + chunkRunId: 'run', + chunkPage: 1, + chunkPages: 2, + records: [ + order, + { + key: '001000000000002AAA', + name: 'Line 0', + objectName: 'Account', + source: 'Salesforce' + } + ] + }), + step({ + chainSequence: 1, + customJobId: 'p2', + chunkRunId: 'run', + chunkPage: 2, + chunkPages: 2, + records: [ + order, + { + key: '001000000000003AAA', + name: 'Line 1', + objectName: 'Account', + source: 'Salesforce' + } + ] + }) + ]); + expect(recordsOf(rows[0]).map(record => record.name)).toEqual([ + 'Order', + 'Line 0', + 'Line 1' + ]); + }); +}); + +describe('tagsFor', () => { + const names = { c1: 'Create invoice' }; + + const labelsOf = tags => tags.map(tag => tag.label); + + it('lists every tag, most specific first, each naming the job it points at', () => { + const row = groupSteps([ + step({ + chainSequence: 2, + customJobId: 'x', + addedByCustomJobId: 'c1', + priority: 1, + dependencies: JSON.stringify([ + { resolvedTargetCustomJobId: 'c2', requiredOutcome: 'SUCCESS' } + ]) + }) + ])[0]; + expect(tagsFor(row, { ...names, c2: 'Reserve stock' })).toEqual([ + { label: '↳ added by Create invoice', targetJobId: 'c1' }, + { label: 'after Reserve stock succeeded', targetJobId: 'c2' }, + { label: 'P1', targetJobId: undefined } + ]); + }); + + it('describes finalizers, chunk pages and non-requeueable failures', () => { + const rows = groupSteps([ + step({ chainSequence: 1, customJobId: 'c1' }), + step({ chainSequence: 1, customJobId: 'f', parentCustomJobId: 'c1' }), + step({ + chainSequence: 2, + customJobId: 'p1', + chunkRunId: 'p1', + chunkPage: 1, + chunkPages: 12, + status: 'ENQUEUED' + }), + step({ + chainSequence: 3, + customJobId: 'bad', + status: 'FAILED', + requeueStatus: 'TooLarge' + }) + ]); + expect(labelsOf(tagsFor(rows[1], names))).toEqual(['finalizer of Create invoice']); + expect(labelsOf(tagsFor(rows[2], names))).toEqual(['chunk · 12 pages']); + const settled = groupSteps([ + step({ + chainSequence: 2, + customJobId: 'q1', + chunkRunId: 'q1', + chunkPage: 1, + chunkPages: 2 + }), + step({ + chainSequence: 2, + customJobId: 'q2', + chunkRunId: 'q1', + chunkPage: 2, + chunkPages: 2 + }) + ]); + expect(labelsOf(tagsFor(settled[0], names))).toEqual(['chunk · 2 pages']); + expect(pageStripFor(settled[0]).summary).toBe('2 of 2 pages'); + expect(labelsOf(tagsFor(rows[3], names))).toEqual(['not requeueable']); + const replayed = groupSteps([ + step({ + chainSequence: 3, + customJobId: 'done', + status: 'FAILED', + requeueStatus: 'Requeued' + }) + ]); + expect(labelsOf(tagsFor(replayed[0], names))).toEqual(['requeued']); + expect(labelsOf(tagsFor(rows[0], names))).toEqual([]); + }); +}); + +describe('nextPollDelay', () => { + it('polls fast while running, slow while queued, not at all when settled', () => { + expect(nextPollDelay({ status: 'running' })).toBe(POLL.running); + expect(nextPollDelay({ status: 'queued' })).toBe(POLL.queued); + expect(nextPollDelay({ status: 'failed' })).toBeNull(); + expect(nextPollDelay({ status: 'completed' })).toBeNull(); + }); +}); + +describe('pollDelayFor', () => { + const now = Date.parse('2026-11-03T14:00:00.000Z'); + const rowsWith = (...overrides) => + groupSteps( + overrides.map((o, i) => step({ chainSequence: i + 1, customJobId: 'q' + i, ...o })) + ); + + it('polls fast while something runs or is finishing on the platform', () => { + expect( + pollDelayFor(rowsWith({ status: 'ENQUEUED', apexJobStatus: 'Processing' }), now) + ).toBe(POLL.running); + expect( + pollDelayFor(rowsWith({ status: 'ENQUEUED', apexJobStatus: 'Completed' }), now) + ).toBe(POLL.running); + }); + + it('polls at the queued rate while a job waits in the flex queue', () => { + expect(pollDelayFor(rowsWith({ status: 'ENQUEUED', apexJobStatus: 'Queued' }), now)).toBe( + POLL.queued + ); + expect(pollDelayFor(rowsWith({ status: 'ENQUEUED' }), now)).toBe(POLL.queued); + }); + + it('sleeps until the earliest retry when every in-flight step is a scheduled retry', () => { + const rows = rowsWith( + { status: 'ENQUEUED', retryAttempts: 1, nextAttemptAt: '2026-11-03T14:02:00.000Z' }, + { status: 'ENQUEUED', retryAttempts: 1, nextAttemptAt: '2026-11-03T14:30:00.000Z' } + ); + expect(pollDelayFor(rows, now)).toBe(120000); + const soon = rowsWith({ + status: 'ENQUEUED', + retryAttempts: 1, + nextAttemptAt: '2026-11-03T14:00:02.000Z' + }); + expect(pollDelayFor(soon, now)).toBe(POLL.queued); + const far = rowsWith({ + status: 'ENQUEUED', + retryAttempts: 1, + nextAttemptAt: '2026-11-03T15:00:00.000Z' + }); + expect(pollDelayFor(far, now)).toBe(POLL.maxSleep); + }); + + it('returns null when nothing is in flight', () => { + expect(pollDelayFor(rowsWith({ status: 'COMPLETED' }), now)).toBeNull(); + }); +}); + +describe('a delayed job', () => { + const inMinutes = minutes => new Date(Date.now() + minutes * 60000).toISOString(); + const delayed = (overrides = {}) => + step({ + status: 'ENQUEUED', + apexJobStatus: 'Queued', + delayMinutes: 5, + nextAttemptAt: inMinutes(5), + ...overrides + }); + + it('is delayed until its time, then plainly queued', () => { + expect(statusOf(delayed())).toEqual({ key: 'delay', label: 'Delayed' }); + expect(statusOf(delayed({ nextAttemptAt: inMinutes(-1) }))).toEqual({ + key: 'wait', + label: 'Queued' + }); + }); + + it('stays enqueued with no time while it is not its turn', () => { + const waiting = delayed({ apexJobStatus: null, nextAttemptAt: null, delayMinutes: 10 }); + const row = groupSteps([waiting])[0]; + expect(row.status).toEqual({ key: 'wait', label: 'Enqueued' }); + expect(delayOf(row)).toEqual({ until: null, plannedMinutes: 10 }); + }); + + it('gives the time on the row once it is waiting, and drops the planned delay', () => { + const next = inMinutes(5); + expect(delayOf(groupSteps([delayed({ nextAttemptAt: next })])[0])).toEqual({ + until: next, + plannedMinutes: null + }); + }); + + it('says nothing about a delay on a job that has none', () => { + expect(delayOf(groupSteps([step({ status: 'ENQUEUED' })])[0])).toEqual({ + until: null, + plannedMinutes: null + }); + }); + + it('does not mistake a retry wait for a delay', () => { + const retry = delayed({ retryAttempts: 1, apexJobStatus: null }); + expect(statusOf(retry).key).toBe('retry'); + }); + + it('shows a run that is running again as running', () => { + expect(statusOf(delayed({ apexJobStatus: 'Processing' })).key).toBe('run'); + }); + + it('makes a chain that has not started delayed, with the start time', () => { + const next = inMinutes(5); + const summary = sequenceSummary(groupSteps([delayed({ nextAttemptAt: next })])); + expect(summary.status).toBe('delayed'); + expect(summary.delayedUntil).toBe(next); + }); + + it('keeps a started chain running and says when the next step goes', () => { + const next = inMinutes(5); + const summary = sequenceSummary( + groupSteps([ + step({ chainSequence: 1, customJobId: 'a', status: 'COMPLETED' }), + delayed({ chainSequence: 2, customJobId: 'b', nextAttemptAt: next }) + ]) + ); + expect(summary.status).toBe('running'); + expect(summary.delayedUntil).toBe(next); + }); + + it('has no delay on a chain once the time has passed', () => { + const summary = sequenceSummary(groupSteps([delayed({ nextAttemptAt: inMinutes(-1) })])); + expect(summary.status).toBe('queued'); + expect(summary.delayedUntil).toBeNull(); + }); + + it('lets row and chain agree the moment the delay is over, without a new poll', () => { + const steps = [delayed({ nextAttemptAt: inMinutes(5) })]; + expect(groupSteps(steps)[0].status.key).toBe('delay'); + const later = Date.now() + 6 * 60000; + const clock = jest.spyOn(Date, 'now').mockReturnValue(later); + expect(groupSteps(steps)[0].status).toEqual({ key: 'wait', label: 'Queued' }); + expect(sequenceSummary(groupSteps(steps)).status).toBe('queued'); + clock.mockRestore(); + }); + + it('sleeps until the time instead of polling', () => { + const now = Date.now(); + const rows = groupSteps([delayed({ nextAttemptAt: new Date(now + 120000).toISOString() })]); + expect(pollDelayFor(rows, now)).toBe(120000); + expect(nextPollDelay(sequenceSummary(rows))).toBe(POLL.queued); + }); + + it('marks a delayed page and keeps the run going', () => { + const next = inMinutes(5); + const row = groupSteps([ + step({ + customJobId: 'p1', + chunkRunId: 'p1', + chunkPage: 1, + chunkPages: 3, + status: 'COMPLETED' + }), + delayed({ + customJobId: 'p2', + chunkRunId: 'p1', + chunkPage: 2, + chunkPages: 3, + nextAttemptAt: next + }) + ])[0]; + expect(row.status.key).toBe('delay'); + expect(delayOf(row).until).toBe(next); + expect(pageStripFor(row).summary).toBe('1 of 3 pages'); + }); +}); + +describe('pageStripFor', () => { + const pages = (count, failed = []) => + Array.from({ length: count }, (_, i) => + step({ + chainSequence: 1, + customJobId: 'p' + i, + chunkPage: i + 1, + chunkPages: count, + status: failed.includes(i + 1) ? 'FAILED' : i < 7 ? 'COMPLETED' : 'ENQUEUED' + }) + ); + + it('draws one square per page up to the cap', () => { + const strip = pageStripFor({ pages: pages(12, [7]), step: { chunkPages: 12 } }); + expect(strip.squares).toHaveLength(12); + expect(strip.squares[6].key).toBe('bad'); + expect(strip.summary).toBe('7 of 12 pages · 1 failed'); + }); + + it('draws the pages that do not exist yet and counts none done while waiting', () => { + const strip = pageStripFor({ + pages: [step({ chunkPage: 1, chunkPages: 8, status: 'ENQUEUED' })], + step: { chunkPages: 8 } + }); + expect(strip.squares.map(square => square.key)).toEqual(['wait', ...Array(7).fill('todo')]); + expect(strip.squares.map(square => square.page)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + expect(strip.summary).toBe('0 of 8 pages'); + }); + + it('names the page only while one is running', () => { + const done = step({ chunkPage: 1, chunkPages: 8, status: 'COMPLETED' }); + const second = status => + step({ chunkPage: 2, chunkPages: 8, status: 'ENQUEUED', apexJobStatus: status }); + const stripWith = page => + pageStripFor({ pages: [done, page], step: { chunkPages: 8 } }).summary; + expect(stripWith(second('Processing'))).toBe('page 2 of 8'); + expect(stripWith(second('Queued'))).toBe('1 of 8 pages'); + }); + + it('switches to a summary bar above the cap', () => { + const failed = [3, 9, 11, 20, 25, 31, 44, 50, 51, 52, 60, 61]; + const strip = pageStripFor({ + pages: pages(CAPS.pageSquares + 40, failed), + step: { chunkPages: CAPS.pageSquares + 40 } + }); + expect(strip.squares).toBeNull(); + expect(strip.percent).toBe(18); + }); +}); + +describe('failedPageGroups', () => { + const page = (number, overrides = {}) => + step({ + customJobId: `p${number}`, + chunkRunId: 'run', + chunkPage: number, + chunkPages: 20, + ...overrides + }); + const failed = (number, message) => + page(number, { + status: 'FAILED', + exceptionType: 'System.DmlException', + exceptionMessage: message + }); + + it('groups failed pages sharing one error and keeps different errors apart', () => { + const row = groupSteps([ + page(1), + failed(9, 'UNABLE_TO_LOCK_ROW'), + failed(3, 'UNABLE_TO_LOCK_ROW'), + failed(5, 'Validation failed') + ])[0]; + const { groups, moreFailed } = failedPageGroups(row); + expect(groups.map(group => group.pages.map(item => item.chunkPage))).toEqual([[3, 9], [5]]); + expect(groups[0].message).toBe('UNABLE_TO_LOCK_ROW'); + expect(groups[0].exceptionType).toBe('System.DmlException'); + expect(moreFailed).toBe(0); + }); + + it('lists ten failed pages and counts the rest', () => { + const pages = Array.from({ length: 13 }, (_, index) => failed(index + 1, 'same')); + const { groups, moreFailed } = failedPageGroups(groupSteps(pages)[0]); + expect(groups[0].pages).toHaveLength(CAPS.failedPagesListed); + expect(moreFailed).toBe(3); + }); +}); + +describe('rowKeyOf', () => { + const rows = groupSteps([ + step({ chainSequence: 1, customJobId: 'job' }), + step({ chainSequence: 2, customJobId: 'p1', chunkRunId: 'run', chunkPage: 1 }), + step({ chainSequence: 3, customJobId: 'p2', chunkRunId: 'run', chunkPage: 2 }) + ]); + + it('finds a job row and the chunk row owning any of its pages', () => { + expect(rowKeyOf(rows, 'job')).toBe('job'); + expect(rowKeyOf(rows, 'p2')).toBe('p1'); + }); + + it('finds nothing for an unknown job or no job', () => { + expect(rowKeyOf(rows, 'gone')).toBeNull(); + expect(rowKeyOf(rows, null)).toBeNull(); + }); +}); + +describe('pluralOf', () => { + it('matches the word to the count', () => { + expect(pluralOf(1, 'page')).toBe('1 page'); + expect(pluralOf(2, 'page')).toBe('2 pages'); + expect(pluralOf(0, 'page')).toBe('0 pages'); + }); + + it('reads right for a one page run in the strip summary', () => { + const row = groupSteps([ + step({ customJobId: 'p1', chunkRunId: 'run', chunkPage: 1, chunkPages: 1 }) + ])[0]; + expect(pageStripFor(row).summary).toBe('1 of 1 page'); + }); +}); + +describe('truncate', () => { + it('cuts long text at the cap with an ellipsis and leaves short text alone', () => { + expect(truncate('x'.repeat(400), CAPS.exceptionChars)).toHaveLength( + CAPS.exceptionChars + 1 + ); + expect(truncate('short', 300)).toBe('short'); + expect(truncate(null, 300)).toBe(''); + }); +}); + +describe('sameData', () => { + it('treats two objects with equal content as the same', () => { + const row = { key: 'a', step: { id: '1', status: 'COMPLETED' }, status: { key: 'ok' } }; + expect(sameData(row, JSON.parse(JSON.stringify(row)))).toBe(true); + }); + + it('sees a changed nested value', () => { + const row = { key: 'a', step: { id: '1', status: 'ENQUEUED' } }; + expect(sameData(row, { key: 'a', step: { id: '1', status: 'COMPLETED' } })).toBe(false); + }); + + it('handles undefined on either side', () => { + expect(sameData(undefined, undefined)).toBe(true); + expect(sameData(undefined, { key: 'a' })).toBe(false); + }); +}); + +describe('sequenceSummary lifecycle', () => { + const page = (n, extra = {}) => + step({ chainSequence: 1, chunkRunId: 'run-1', chunkPage: n, chunkPages: 10, ...extra }); + + it('is queued only while nothing has started', () => { + const queued = sequenceSummary( + groupSteps([ + step({ chainSequence: 1, status: 'ENQUEUED', apexJobStatus: 'Queued' }), + step({ chainSequence: 2, status: 'ENQUEUED' }) + ]) + ); + expect(queued.status).toBe('queued'); + expect(queued.activity).toBe('waiting'); + }); + + it('stays running between steps once one has finished', () => { + const between = sequenceSummary( + groupSteps([ + step({ chainSequence: 1 }), + step({ chainSequence: 2, status: 'ENQUEUED', apexJobStatus: 'Queued' }) + ]) + ); + expect(between.status).toBe('running'); + expect(between.activity).toBe('waiting'); + }); + + it('reports what the chain is doing now', () => { + const executing = sequenceSummary( + groupSteps([ + step({ chainSequence: 1, status: 'ENQUEUED', apexJobStatus: 'Processing' }) + ]) + ); + expect(executing.activity).toBe('executing'); + const retrying = sequenceSummary( + groupSteps([ + step({ + chainSequence: 1, + status: 'ENQUEUED', + retryAttempts: 1, + nextAttemptAt: '2026-11-02T10:05:00.000Z' + }) + ]) + ); + expect(retrying.activity).toBe('retrying'); + }); + + it('counts finished pages as started, so a chunk run with a queued page is running, not queued', () => { + const rows = groupSteps([ + page(1), + page(2), + page(3, { status: 'ENQUEUED', apexJobStatus: 'Queued' }) + ]); + expect(sequenceSummary(rows).status).toBe('running'); + const nothingYet = groupSteps([page(1, { status: 'ENQUEUED', apexJobStatus: 'Queued' })]); + expect(sequenceSummary(nothingYet).status).toBe('queued'); + }); + + it('keeps a chunk run running while pages are missing, even with a failed page and nothing in flight', () => { + const rows = groupSteps([page(1), page(2, { status: 'FAILED' }), page(3)]); + const summary = sequenceSummary(rows, Date.parse('2026-11-02T10:00:30.000Z')); + expect(summary.status).toBe('running'); + expect(summary.failedSteps).toBe(1); + }); + + it('counts failed pages and failed jobs for the badge tail', () => { + const rows = groupSteps([ + page(1, { status: 'FAILED' }), + page(2, { status: 'FAILED' }), + page(3), + step({ chainSequence: 2, status: 'FAILED' }) + ]); + expect(sequenceSummary(rows).failedSteps).toBe(3); + }); + + it('treats a chunk run with missing pages as finished once it has been quiet for ten minutes', () => { + const rows = groupSteps([ + page(1, { finishedAt: '2026-11-02T10:00:00.000Z' }), + page(2, { status: 'FAILED', finishedAt: '2026-11-02T10:00:05.000Z' }) + ]); + const stale = sequenceSummary(rows, Date.parse('2026-11-02T10:11:00.000Z')); + expect(stale.status).toBe('failed'); + const fresh = sequenceSummary(rows, Date.parse('2026-11-02T10:05:00.000Z')); + expect(fresh.status).toBe('running'); + }); + + it('finishes a chunk run when every page is there or the run was stopped', () => { + const all = groupSteps([...Array(10).keys()].map(i => page(i + 1))); + expect(sequenceSummary(all).status).toBe('completed'); + const stopped = groupSteps([ + page(1), + page(2, { status: 'FAILED' }), + page(3, { status: 'SKIPPED_CHUNK_STOPPED' }) + ]); + expect(sequenceSummary(stopped).status).toBe('failed'); + }); +}); + +describe('statusOf finalizer gap', () => { + it('shows an enqueued step whose platform job failed as finishing, not failed', () => { + expect(statusOf({ status: 'ENQUEUED', apexJobStatus: 'Failed' })).toEqual({ + key: 'run', + label: 'Finishing' + }); + expect(statusOf({ status: 'ENQUEUED', apexJobStatus: 'Aborted' }).key).toBe('bad'); + }); +}); + +describe('progressOf', () => { + it('lets chunk pages drive the chain progress', () => { + const page = (n, extra = {}) => + step({ chainSequence: 2, chunkRunId: 'run-1', chunkPage: n, chunkPages: 10, ...extra }); + const rows = groupSteps([ + step({ chainSequence: 1 }), + page(1), + page(2, { status: 'FAILED' }), + page(3, { status: 'ENQUEUED', apexJobStatus: 'Processing' }) + ]); + const progress = sequenceSummary(rows).progress; + expect(progress).toMatchObject({ + total: 11, + completed: 2, + failed: 1, + running: 1, + pending: 7, + steps: 2, + stepsCompleted: 1, + pages: 10, + pagesDone: 2, + percent: 18 + }); + }); +}); + +describe('progressOf after the chunk run', () => { + it('counts a finished chunk run as one step once plain steps exist', () => { + const page = (n, extra = {}) => + step({ chainSequence: 2, chunkRunId: 'run-1', chunkPage: n, chunkPages: 2, ...extra }); + const rows = groupSteps([ + step({ chainSequence: 1 }), + page(1), + page(2, { status: 'FAILED' }) + ]); + expect(sequenceSummary(rows).progress).toMatchObject({ + total: 2, + steps: 2, + stepsCompleted: 1, + failed: 1, + pages: 0 + }); + }); + + it('keeps pages for a chain that is only a chunk run', () => { + const page = (n, extra = {}) => + step({ chainSequence: 1, chunkRunId: 'run-1', chunkPage: n, chunkPages: 2, ...extra }); + const rows = groupSteps([page(1), page(2, { status: 'FAILED' })]); + expect(sequenceSummary(rows).progress).toMatchObject({ pages: 2, pagesDone: 2, failed: 1 }); + }); +}); + +describe('groupSteps cache', () => { + it('returns the same rows for the same steps and fresh rows for new steps', () => { + const steps = [step({ chainSequence: 1 })]; + const rows = groupSteps(steps); + expect(groupSteps(steps)).toBe(rows); + expect(groupSteps([...steps])).not.toBe(rows); + }); + + it('still handles missing steps', () => { + expect(groupSteps(undefined)).toEqual([]); + }); +}); diff --git a/force-app/main/default/lwc/asyncJobTimelineModel/asyncJobTimelineModel.js b/force-app/main/default/lwc/asyncJobTimelineModel/asyncJobTimelineModel.js new file mode 100644 index 0000000..5a5bf7b --- /dev/null +++ b/force-app/main/default/lwc/asyncJobTimelineModel/asyncJobTimelineModel.js @@ -0,0 +1,585 @@ +import USER_LOCALE from '@salesforce/i18n/locale'; + +export const VERSION = '3.1.0'; +export const SOURCE_SALESFORCE = 'Salesforce'; + +export const CAPS = { + stepsCollapsed: 20, + stepsMax: 200, + pageSquares: 60, + failedPagesListed: 10, + tags: 2, + chips: 3, + dots: 16, + dotsPerChunk: 3, + chipsExpanded: 20, + exceptionChars: 300, + mapBoxes: 40, + mapCollapseRun: 5 +}; + +export const POLL = { + running: 3000, + queued: 10000, + afterRequeue: 2000, + graceAfterRequeue: 120000, + maxSleep: 300000, + staleMinutes: 10, + maxMinutes: 30, + maxFailures: 3 +}; + +export const RANGES = [ + { days: 1, label: 'Last 24 hours' }, + { days: 7, label: 'Last 7 days' }, + { days: 30, label: 'Last 30 days' }, + { days: 0, label: 'All time' } +]; + +const SKIP_LABELS = { + SKIPPED_DEPENDENCY: 'Skipped · dependency', + SKIPPED_CHAIN_STOPPED: 'Skipped · chain stopped', + SKIPPED_CHUNK_STOPPED: 'Skipped · chunk run stopped', + SKIPPED_EXPLICIT: 'Skipped · by code', + SKIPPED_DISABLED: 'Skipped · disabled' +}; + +const PLATFORM_RUNNING = new Set(['Processing', 'Preparing']); +// The platform reports Completed or Failed as soon as execute() returns; the finalizer that writes +// the row runs in its own transaction, often seconds later. The step is not queued any more, nor +// done. Aborted jobs get no finalizer, so that one is final. +const PLATFORM_FINISHING = new Set(['Completed', 'Failed']); +const PLATFORM_ENDED = { Aborted: 'Aborted' }; +const OUTCOME_WORDS = { SUCCESS: 'succeeded', FAILURE: 'failed', COMPLETED: 'finished' }; + +export function statusOf(step) { + if (step.status === 'COMPLETED') return { key: 'ok', label: 'Completed' }; + if (step.status === 'FAILED') return { key: 'bad', label: 'Failed' }; + if (step.status === 'FRAMEWORK_ERROR') return { key: 'bad', label: 'Framework error' }; + if (SKIP_LABELS[step.status]) return { key: 'skip', label: SKIP_LABELS[step.status] }; + if (step.status === 'ENQUEUED') return enqueuedStatusOf(step); + return { key: 'wait', label: step.status || 'Unknown' }; +} + +const isWaitingOutDelay = step => + !step.retryAttempts && Date.parse(step.nextAttemptAt) > Date.now(); + +function enqueuedStatusOf(step) { + if (step.nextAttemptAt && step.retryAttempts > 0) return { key: 'retry', label: 'Retrying' }; + if (PLATFORM_RUNNING.has(step.apexJobStatus)) return { key: 'run', label: 'Running' }; + if (PLATFORM_FINISHING.has(step.apexJobStatus)) return { key: 'run', label: 'Finishing' }; + if (PLATFORM_ENDED[step.apexJobStatus]) + return { key: 'bad', label: PLATFORM_ENDED[step.apexJobStatus] }; + if (isWaitingOutDelay(step)) return { key: 'delay', label: 'Delayed' }; + if (step.apexJobStatus) return { key: 'wait', label: 'Queued' }; + return { key: 'wait', label: 'Enqueued' }; +} + +const bySequence = (a, b) => { + const seqA = a.chainSequence ?? Number.MAX_SAFE_INTEGER; + const seqB = b.chainSequence ?? Number.MAX_SAFE_INTEGER; + if (seqA !== seqB) return seqA - seqB; + return String(a.createdAt || '').localeCompare(String(b.createdAt || '')); +}; + +const rowsBySteps = new WeakMap(); + +const delayHasEnded = row => row.status.key === 'delay' && delayOf(row).until === null; + +export function groupSteps(steps) { + if (!steps) return buildRows([]); + const cached = rowsBySteps.get(steps); + if (cached && !cached.some(delayHasEnded)) return cached; + const rows = buildRows(steps); + rowsBySteps.set(steps, rows); + return rows; +} + +function buildRows(steps) { + const sorted = [...steps].sort(bySequence); + const topLevel = []; + const finalizersByParent = new Map(); + const chunkRows = new Map(); + sorted.forEach(step => { + if (step.parentCustomJobId) { + const siblings = finalizersByParent.get(step.parentCustomJobId) || []; + siblings.push(step); + finalizersByParent.set(step.parentCustomJobId, siblings); + return; + } + if (step.chunkRunId) { + const run = chunkRows.get(step.chunkRunId); + if (run) { + run.pages.push(step); + return; + } + const row = { kind: 'chunk', step, pages: [step], depth: 0 }; + chunkRows.set(step.chunkRunId, row); + topLevel.push(row); + return; + } + topLevel.push({ kind: 'job', step, depth: 0 }); + }); + const known = new Set(topLevel.map(row => row.step.customJobId)); + const rows = []; + topLevel.forEach(row => { + rows.push(row); + (finalizersByParent.get(row.step.customJobId) || []).forEach(finalizer => { + rows.push({ kind: 'finalizer', step: finalizer, depth: 1 }); + }); + }); + finalizersByParent.forEach((orphans, parent) => { + if (known.has(parent)) return; + orphans.forEach(orphan => rows.push({ kind: 'finalizer', step: orphan, depth: 0 })); + }); + return rows.map(row => ({ + ...row, + key: row.step.customJobId || row.step.id, + status: rowStatusOf(row) + })); +} + +const STATUS_BUCKET = { + ok: 'completed', + bad: 'failed', + skip: 'skipped', + wait: 'waiting', + delay: 'waiting', + run: 'running', + retry: 'waiting' +}; + +const CHUNK_RUN_ENDED = new Set(['SKIPPED_CHUNK_STOPPED', 'FRAMEWORK_ERROR']); + +const pagesStillComing = row => + row.kind === 'chunk' && + row.step.chunkPages > row.pages.length && + !row.pages.some(page => CHUNK_RUN_ENDED.has(page.status)); + +export const chunkRunAlive = row => + row.kind === 'chunk' && + (['run', 'wait', 'delay', 'retry'].includes(row.status.key) || pagesStillComing(row)); + +function rowStatusOf(row) { + if (row.kind !== 'chunk') return statusOf(row.step); + const statuses = row.pages.map(statusOf); + const inFlight = statuses.find(status => + ['run', 'retry', 'wait', 'delay'].includes(status.key) + ); + if (inFlight) return inFlight; + const failed = statuses.filter(status => status.key === 'bad').length; + if (failed > 0) return { key: 'bad', label: `${failed} page${failed === 1 ? '' : 's'} failed` }; + if (row.pages.some(page => page.status === 'SKIPPED_CHUNK_STOPPED')) { + const lastDone = row.pages.filter(page => statusOf(page).key === 'ok').length; + return { key: 'skip', label: `Stopped after page ${lastDone}` }; + } + const skipped = statuses.find(status => status.key === 'skip'); + return skipped || { key: 'ok', label: 'Completed' }; +} + +export function expectedPagesOf(row) { + return Math.max(row.step.chunkPages || 0, row.pages.length); +} + +export function progressOf(rows) { + const progress = { + total: 0, + completed: 0, + failed: 0, + skipped: 0, + running: 0, + waiting: 0, + pending: 0, + steps: 0, + stepsCompleted: 0, + pages: 0, + pagesDone: 0 + }; + const steps = rows.filter(row => row.kind !== 'chunk'); + const byPages = steps.length === 0 || rows.some(chunkRunAlive); + rows.forEach(row => { + if (row.kind !== 'chunk' || !byPages) { + progress.steps += 1; + progress.total += 1; + progress[STATUS_BUCKET[row.status.key]] += 1; + if (row.status.key === 'ok') progress.stepsCompleted += 1; + return; + } + if (steps.length > 0) { + progress.steps += 1; + if (row.status.key === 'ok') progress.stepsCompleted += 1; + } + const expected = expectedPagesOf(row); + progress.pages += expected; + progress.total += expected; + row.pages.forEach(page => { + const key = statusOf(page).key; + progress[STATUS_BUCKET[key]] += 1; + if (['ok', 'bad', 'skip'].includes(key)) progress.pagesDone += 1; + }); + if (!row.pages.some(page => CHUNK_RUN_ENDED.has(page.status))) + progress.pending += expected - row.pages.length; + }); + progress.percent = + progress.total === 0 ? 0 : Math.round((progress.completed / progress.total) * 100); + return progress; +} + +const HELD_BY_PLATFORM = row => + ['retry', 'delay'].includes(row.status.key) || + (row.status.key === 'wait' && row.status.label === 'Queued'); + +export function currentRowKey(rows) { + const running = rows.find(row => row.status.key === 'run'); + const current = running || rows.find(HELD_BY_PLATFORM); + return current ? current.key : null; +} + +export const QUIET_RUN_MS = POLL.staleMinutes * 60 * 1000; + +export function sequenceSummary(rows, now = Date.now()) { + const summary = { + total: rows.length, + completed: 0, + failed: 0, + skipped: 0, + waiting: 0, + running: 0, + failedSteps: 0, + settledSteps: 0, + delayedUntil: null + }; + let earliest = null; + let latest = null; + let lastActivity = null; + rows.forEach(row => { + summary[STATUS_BUCKET[row.status.key]] += 1; + const steps = row.kind === 'chunk' ? row.pages : [row.step]; + steps.forEach(step => { + const key = statusOf(step).key; + if (key === 'bad') summary.failedSteps += 1; + if (key === 'delay') summary.delayedUntil = step.nextAttemptAt; + if (['ok', 'bad', 'skip'].includes(key)) summary.settledSteps += 1; + const started = Date.parse(step.startedAt || step.createdAt); + const finished = Date.parse(step.finishedAt); + if (!Number.isNaN(started)) + earliest = earliest === null ? started : Math.min(earliest, started); + if (!Number.isNaN(finished)) + latest = latest === null ? finished : Math.max(latest, finished); + lastActivity = Math.max(lastActivity ?? 0, started || 0, finished || 0) || null; + }); + }); + summary.progress = progressOf(rows); + summary.percent = summary.progress.percent; + summary.activity = activityOf(rows); + summary.status = overallStatus(summary, rows, lastActivity, now); + summary.durationMs = + earliest !== null && latest !== null && latest >= earliest ? latest - earliest : null; + return summary; +} + +function activityOf(rows) { + if (rows.some(row => row.status.key === 'run')) return 'executing'; + if (rows.some(row => row.status.key === 'retry')) return 'retrying'; + return 'waiting'; +} + +function overallStatus(summary, rows, lastActivity, now) { + if (summary.settledSteps === 0 && summary.activity !== 'executing') + return summary.delayedUntil ? 'delayed' : 'queued'; + const quiet = lastActivity !== null && now - lastActivity > QUIET_RUN_MS; + const inFlight = summary.running + summary.waiting > 0; + if (inFlight || (rows.some(pagesStillComing) && !quiet)) return 'running'; + if (summary.failed > 0) return 'failed'; + if (rows.some(row => row.step.status === 'SKIPPED_CHAIN_STOPPED')) return 'stopped'; + return 'completed'; +} + +export function collapseCompletedRuns(rows, cap = CAPS.stepsCollapsed) { + if (rows.length <= cap) return rows; + const keep = rows.map( + (row, index) => index < 3 || index >= rows.length - 2 || row.status.key !== 'ok' + ); + const collapsed = []; + let hidden = []; + const flush = () => { + if (hidden.length === 0) return; + collapsed.push({ + kind: 'gap', + key: 'gap-' + hidden[0].key, + hiddenCount: hidden.length, + hiddenRows: hidden + }); + hidden = []; + }; + rows.forEach((row, index) => { + if (keep[index]) { + flush(); + collapsed.push(row); + } else { + hidden.push(row); + } + }); + flush(); + return collapsed; +} + +const statusSignature = row => row.status.key + '|' + row.status.label; + +export function diffRows(previousRows, nextRows) { + const added = new Set(); + const changed = new Set(); + if (!previousRows) return { added, changed }; + const previousByKey = new Map(previousRows.map(row => [row.key, statusSignature(row)])); + nextRows.forEach(row => { + if (!previousByKey.has(row.key)) added.add(row.key); + else if (previousByKey.get(row.key) !== statusSignature(row)) changed.add(row.key); + }); + return { added, changed }; +} + +export function formatDuration(ms) { + if (ms === null || ms === undefined || Number.isNaN(ms) || ms < 0) return null; + if (ms < 100) return '<0.1 s'; + if (ms < 1000) return (ms / 1000).toFixed(1) + ' s'; + if (ms < 60000) return Math.round(ms / 1000) + ' s'; + const totalSeconds = Math.round(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours} h ${minutes} min`; + return `${minutes} min ${seconds} s`; +} + +const MINUTE = 60000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const timeOf = date => date.toLocaleTimeString(USER_LOCALE, { hour: '2-digit', minute: '2-digit' }); + +export function relativeTime(iso, now = Date.now()) { + const at = Date.parse(iso); + if (!iso || Number.isNaN(at)) return null; + const date = new Date(at); + const full = date.toLocaleString(USER_LOCALE, { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + const age = now - at; + let text; + if (age < MINUTE) text = 'just now'; + else if (age < HOUR) text = `${Math.floor(age / MINUTE)} min ago`; + else if (age < DAY) { + const hours = Math.floor(age / HOUR); + const minutes = Math.floor((age % HOUR) / MINUTE); + text = minutes ? `${hours} h ${minutes} min ago` : `${hours} h ago`; + } else if (age < 2 * DAY) text = `Yesterday, ${timeOf(date)}`; + else if (age < 7 * DAY) + text = `${date.toLocaleDateString(USER_LOCALE, { weekday: 'short' })}, ${timeOf(date)}`; + else + text = date.toLocaleString(USER_LOCALE, { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + return { text, full }; +} + +export function recordsOf(row) { + const steps = row.kind === 'chunk' ? row.pages : [row.step]; + const seen = new Set(); + const records = []; + steps.forEach(step => { + (step.records || []).forEach(record => { + const identity = `${record.source || ''}|${record.key}`; + if (seen.has(identity)) return; + seen.add(identity); + records.push(record); + }); + }); + return records; +} + +export function tagsFor(row, nameByCustomJobId = {}) { + const step = row.step; + const nameOf = customJobId => nameByCustomJobId[customJobId] || 'an earlier job'; + const tags = []; + const addTag = (label, targetJobId) => tags.push({ label, targetJobId }); + if (row.kind === 'finalizer' && step.parentCustomJobId) + addTag('finalizer of ' + nameOf(step.parentCustomJobId), step.parentCustomJobId); + if (step.addedByCustomJobId) + addTag('↳ added by ' + nameOf(step.addedByCustomJobId), step.addedByCustomJobId); + if (row.kind === 'chunk') addTag(chunkTagOf(row)); + parsedDependencies(step).forEach(dependency => { + const target = dependency.resolvedTargetCustomJobId; + addTag( + `after ${nameOf(target)} ${OUTCOME_WORDS[dependency.requiredOutcome] || 'finished'}`, + target + ); + }); + if (step.priority !== null && step.priority !== undefined) addTag('P' + step.priority); + const settledBadly = ['bad', 'skip'].includes(row.status.key); + if (settledBadly && step.requeueStatus === 'Requeued') addTag('requeued'); + else if (settledBadly && step.requeueStatus !== 'Stored') addTag('not requeueable'); + return tags; +} + +export function rowKeyOf(rows, customJobId) { + if (!customJobId) return null; + const owner = rows.find( + row => row.key === customJobId || row.pages?.some(page => page.customJobId === customJobId) + ); + return owner?.key ?? null; +} + +export function dotsFit(rows) { + if (rows.every(row => row.kind === 'chunk')) return false; + const needed = rows.reduce( + (sum, row) => sum + (row.kind === 'chunk' ? CAPS.dotsPerChunk : 1), + 0 + ); + return needed <= CAPS.dots; +} + +export function pluralOf(count, word) { + return `${count} ${word}${count === 1 ? '' : 's'}`; +} + +function chunkTagOf(row) { + return `chunk · ${pluralOf(expectedPagesOf(row), 'page')}`; +} + +function parsedDependencies(step) { + if (!step.dependencies) return []; + try { + const parsed = JSON.parse(step.dependencies); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function nextPollDelay(summary) { + if (summary.status === 'running') return POLL.running; + if (['queued', 'delayed'].includes(summary.status)) return POLL.queued; + return null; +} + +export function pollDelayFor(rows, now = Date.now()) { + const inFlight = rows + .flatMap(row => (row.kind === 'chunk' ? row.pages : [row.step])) + .map(step => ({ step, status: statusOf(step) })) + .filter(({ status }) => ['run', 'wait', 'delay', 'retry'].includes(status.key)); + if (inFlight.length === 0) return null; + if (inFlight.some(({ status }) => status.key === 'run')) return POLL.running; + if (inFlight.some(({ status }) => status.key === 'wait')) return POLL.queued; + const dueIn = Math.min(...inFlight.map(({ step }) => Date.parse(step.nextAttemptAt) - now)); + if (Number.isNaN(dueIn) || dueIn <= POLL.queued) return POLL.queued; + return Math.min(dueIn, POLL.maxSleep); +} + +export function delayOf(row) { + const steps = row.kind === 'chunk' ? row.pages : [row.step]; + const delayed = steps.find(step => statusOf(step).key === 'delay'); + const waitingForItsTurn = row.status.key === 'wait' && row.status.label === 'Enqueued'; + return { + until: delayed?.nextAttemptAt ?? null, + plannedMinutes: !delayed && waitingForItsTurn ? (row.step.delayMinutes ?? null) : null + }; +} + +export function clockOf(iso) { + return new Date(iso).toLocaleTimeString(USER_LOCALE, { hour: '2-digit', minute: '2-digit' }); +} + +function squaresFor(statuses, total) { + const statusByPage = new Map(statuses.map(status => [status.page, status])); + return Array.from({ length: total }, (_, index) => ({ + page: index + 1, + key: statusByPage.get(index + 1)?.key || 'todo' + })); +} + +export function pageStripFor(row) { + const pages = [...row.pages].sort((a, b) => (a.chunkPage || 0) - (b.chunkPage || 0)); + const total = expectedPagesOf(row); + const statuses = pages.map(page => ({ page: page.chunkPage, key: statusOf(page).key })); + const failedCount = statuses.filter(status => status.key === 'bad').length; + const done = statuses.filter(status => ['ok', 'bad', 'skip'].includes(status.key)).length; + const current = statuses.find(status => ['run', 'retry'].includes(status.key)); + const summary = + (current ? `page ${current.page} of ${total}` : `${done} of ${pluralOf(total, 'page')}`) + + (failedCount ? ` · ${failedCount} failed` : ''); + return { + squares: total <= CAPS.pageSquares ? squaresFor(statuses, total) : null, + summary, + percent: total === 0 ? 0 : Math.round((done / total) * 100) + }; +} + +export function failedPageGroups(row) { + const failed = row.pages + .filter(page => statusOf(page).key === 'bad') + .sort((a, b) => (a.chunkPage || 0) - (b.chunkPage || 0)); + const groups = new Map(); + failed.slice(0, CAPS.failedPagesListed).forEach(page => { + const key = `${page.exceptionType || ''}|${page.exceptionMessage || ''}`; + if (!groups.has(key)) + groups.set(key, { + key, + exceptionType: page.exceptionType, + message: page.exceptionMessage, + pages: [] + }); + groups.get(key).pages.push(page); + }); + return { + groups: [...groups.values()], + moreFailed: Math.max(failed.length - CAPS.failedPagesListed, 0) + }; +} + +export function truncate(text, cap) { + if (!text) return ''; + return text.length <= cap ? text : text.slice(0, cap) + '…'; +} + +export function sameData(a, b) { + return JSON.stringify(a) === JSON.stringify(b); +} + +const STRIP_COLORS = { ok: 'var(--ok)', bad: 'var(--bad)', skip: 'var(--warn)', run: 'var(--run)' }; +const STRIP_MIN_PERCENT = 3; + +export function pageStripStyle(row) { + const total = expectedPagesOf(row); + const counts = { ok: 0, bad: 0, skip: 0, run: 0 }; + row.pages.forEach(page => { + const key = statusOf(page).key; + counts[key in counts ? key : 'run'] += 1; + }); + const widths = Object.fromEntries( + Object.keys(counts).map(key => { + const share = total === 0 ? 0 : (counts[key] / total) * 100; + const floor = key === 'bad' || key === 'skip' ? STRIP_MIN_PERCENT : 0; + return [key, counts[key] ? Math.max(share, floor) : 0]; + }) + ); + const spill = Math.max(0, widths.ok + widths.bad + widths.skip + widths.run - 100); + widths.ok = Math.max(0, widths.ok - spill); + const stops = []; + let at = 0; + Object.keys(widths).forEach(key => { + if (!widths[key]) return; + const to = at + widths[key]; + stops.push(`${STRIP_COLORS[key]} ${at.toFixed(1)}% ${to.toFixed(1)}%`); + at = to; + }); + stops.push(`var(--line) ${at.toFixed(1)}% 100%`); + return `background:linear-gradient(to right, ${stops.join(', ')})`; +} diff --git a/force-app/main/default/lwc/asyncJobTimelineModel/asyncJobTimelineModel.js-meta.xml b/force-app/main/default/lwc/asyncJobTimelineModel/asyncJobTimelineModel.js-meta.xml new file mode 100644 index 0000000..00a544f --- /dev/null +++ b/force-app/main/default/lwc/asyncJobTimelineModel/asyncJobTimelineModel.js-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + false + diff --git a/force-app/main/default/objects/AsyncResultLink__c/AsyncResultLink__c.object-meta.xml b/force-app/main/default/objects/AsyncResultLink__c/AsyncResultLink__c.object-meta.xml new file mode 100644 index 0000000..b3f892f --- /dev/null +++ b/force-app/main/default/objects/AsyncResultLink__c/AsyncResultLink__c.object-meta.xml @@ -0,0 +1,25 @@ + + + AsyncResultLinkCompact + Deployed + Links an Async Result to a record the job worked on, in Salesforce or in another system. Written by Async Lib for linkRecord(s) and linkExternalRecord(s) on the builder and Async.linkRecord(s) / Async.linkExternalRecord(s) inside work(). One row per result and record. + false + true + false + false + false + true + false + true + true + + + ARL-{00000} + + AutoNumber + + Async Result Links + + ControlledByParent + Public + diff --git a/force-app/main/default/objects/AsyncResultLink__c/compactLayouts/AsyncResultLinkCompact.compactLayout-meta.xml b/force-app/main/default/objects/AsyncResultLink__c/compactLayouts/AsyncResultLinkCompact.compactLayout-meta.xml new file mode 100644 index 0000000..8da7297 --- /dev/null +++ b/force-app/main/default/objects/AsyncResultLink__c/compactLayouts/AsyncResultLinkCompact.compactLayout-meta.xml @@ -0,0 +1,10 @@ + + + AsyncResultLinkCompact + Name + Key__c + Source__c + ObjectName__c + AsyncResult__c + + diff --git a/force-app/main/default/objects/AsyncResultLink__c/fields/AsyncResult__c.field-meta.xml b/force-app/main/default/objects/AsyncResultLink__c/fields/AsyncResult__c.field-meta.xml new file mode 100644 index 0000000..853dda2 --- /dev/null +++ b/force-app/main/default/objects/AsyncResultLink__c/fields/AsyncResult__c.field-meta.xml @@ -0,0 +1,15 @@ + + + AsyncResult__c + The result this link belongs to. Deleting the result deletes the link. + false + + AsyncResult__c + Links + Links + 0 + false + false + MasterDetail + false + diff --git a/force-app/main/default/objects/AsyncResultLink__c/fields/Key__c.field-meta.xml b/force-app/main/default/objects/AsyncResultLink__c/fields/Key__c.field-meta.xml new file mode 100644 index 0000000..3d212da --- /dev/null +++ b/force-app/main/default/objects/AsyncResultLink__c/fields/Key__c.field-meta.xml @@ -0,0 +1,12 @@ + + + Key__c + Identifier of the linked record: a Salesforce Id, or the record's key in the source system. Indexed, so a record page or an integration can select by it. + true + + 255 + false + false + Text + false + diff --git a/force-app/main/default/objects/AsyncResultLink__c/fields/ObjectName__c.field-meta.xml b/force-app/main/default/objects/AsyncResultLink__c/fields/ObjectName__c.field-meta.xml new file mode 100644 index 0000000..4c56b72 --- /dev/null +++ b/force-app/main/default/objects/AsyncResultLink__c/fields/ObjectName__c.field-meta.xml @@ -0,0 +1,12 @@ + + + ObjectName__c + Object the linked record belongs to. The API name, set by the framework, for a Salesforce Id; whatever the caller passed for an external key. + false + + 80 + false + false + Text + false + diff --git a/force-app/main/default/objects/AsyncResultLink__c/fields/Source__c.field-meta.xml b/force-app/main/default/objects/AsyncResultLink__c/fields/Source__c.field-meta.xml new file mode 100644 index 0000000..217c7a0 --- /dev/null +++ b/force-app/main/default/objects/AsyncResultLink__c/fields/Source__c.field-meta.xml @@ -0,0 +1,21 @@ + + + Source__c + System the key belongs to. Salesforce for links made with linkRecord(s); whatever the caller passed to linkExternalRecord(s), blank when nothing was passed. Add your own values. + false + + false + false + Picklist + + false + + false + + Salesforce + false + + + + + diff --git a/force-app/main/default/objects/AsyncResultLink__c/fields/UniqueKey__c.field-meta.xml b/force-app/main/default/objects/AsyncResultLink__c/fields/UniqueKey__c.field-meta.xml new file mode 100644 index 0000000..5e44a42 --- /dev/null +++ b/force-app/main/default/objects/AsyncResultLink__c/fields/UniqueKey__c.field-meta.xml @@ -0,0 +1,13 @@ + + + UniqueKey__c + Result id plus a hash of source and key, so the database rejects a second link to the same record. Set by the framework. + false + + 59 + false + false + Text + true + true + diff --git a/force-app/main/default/objects/AsyncResult__c/AsyncResult__c.object-meta.xml b/force-app/main/default/objects/AsyncResult__c/AsyncResult__c.object-meta.xml index 7c1092a..a2c5ba9 100644 --- a/force-app/main/default/objects/AsyncResult__c/AsyncResult__c.object-meta.xml +++ b/force-app/main/default/objects/AsyncResult__c/AsyncResult__c.object-meta.xml @@ -132,16 +132,20 @@ View + AsyncResultRecordPage Large - Default + false + Flexipage View + AsyncResultRecordPage Small - Default + false + Flexipage false - SYSTEM + AsyncResultCompact Deployed Results of Async Jobs that are using the Async Framework. false @@ -161,7 +165,21 @@ AutoNumber Async Results - + + Status__c + JobName__c + ChainName__c + ClassName__c + StartedAt__c + FinishedAt__c + Status__c + JobName__c + ChainName__c + Status__c + JobName__c + ChainName__c + ClassName__c + ReadWrite Public diff --git a/force-app/main/default/objects/AsyncResult__c/compactLayouts/AsyncResultCompact.compactLayout-meta.xml b/force-app/main/default/objects/AsyncResult__c/compactLayouts/AsyncResultCompact.compactLayout-meta.xml new file mode 100644 index 0000000..244c394 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/compactLayouts/AsyncResultCompact.compactLayout-meta.xml @@ -0,0 +1,11 @@ + + + AsyncResultCompact + Name + Status__c + JobName__c + ClassName__c + StartedAt__c + ChainName__c + + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/AddedByCustomJobId__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/AddedByCustomJobId__c.field-meta.xml new file mode 100644 index 0000000..89ddf04 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/AddedByCustomJobId__c.field-meta.xml @@ -0,0 +1,12 @@ + + + AddedByCustomJobId__c + For a job added while another job was running, by chain(), enqueue() or nextInChain(), the Custom Job Id of the job that added it. Blank when nothing was running. + false + + 36 + false + false + Text + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/ChainName__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/ChainName__c.field-meta.xml new file mode 100644 index 0000000..b599bb8 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/ChainName__c.field-meta.xml @@ -0,0 +1,12 @@ + + + ChainName__c + Display name given with chainName(...) on any job of the chain; the last one set wins and every result in the chain carries it. Blank when the chain was not named. + false + + 80 + false + false + Text + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/ChainSequence__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/ChainSequence__c.field-meta.xml new file mode 100644 index 0000000..0c156bf --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/ChainSequence__c.field-meta.xml @@ -0,0 +1,13 @@ + + + ChainSequence__c + Position of the job inside its chain, in the order the chain runs. Can change only while the job waits, when a job that runs earlier is added. Order by Chain Id, then this, to read a chain as a timeline. Async.requeue() replays rows in this order. + false + + 9 + false + 0 + false + Number + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/ChunkPage__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/ChunkPage__c.field-meta.xml new file mode 100644 index 0000000..89aa6f7 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/ChunkPage__c.field-meta.xml @@ -0,0 +1,13 @@ + + + ChunkPage__c + For a chunk page, its number inside the run, starting at 1. + false + + 9 + false + 0 + false + Number + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/ChunkPages__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/ChunkPages__c.field-meta.xml new file mode 100644 index 0000000..7086ebf --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/ChunkPages__c.field-meta.xml @@ -0,0 +1,13 @@ + + + ChunkPages__c + For a chunk page, how many pages the run has in total. + false + + 9 + false + 0 + false + Number + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/ChunkRunId__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/ChunkRunId__c.field-meta.xml new file mode 100644 index 0000000..6755ceb --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/ChunkRunId__c.field-meta.xml @@ -0,0 +1,12 @@ + + + ChunkRunId__c + For a chunk page, the id shared by every page of the same run (the Custom Job Id of the first page). Group by this to see a run as one step. + false + + 36 + false + false + Text + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/DelayMinutes__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/DelayMinutes__c.field-meta.xml new file mode 100644 index 0000000..238ed77 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/DelayMinutes__c.field-meta.xml @@ -0,0 +1,13 @@ + + + DelayMinutes__c + The delay in minutes set on the job with delay(), asyncOptions() or delayBetweenChunks(). A backoff between retries does not count. + false + + 9 + false + 0 + false + Number + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/Dependencies__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/Dependencies__c.field-meta.xml new file mode 100644 index 0000000..6936bd1 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/Dependencies__c.field-meta.xml @@ -0,0 +1,12 @@ + + + Dependencies__c + The dependsOn(...) rules of the job as JSON: the Custom Job Id each one targets and the outcome it requires. Async.requeue() rebuilds them when the target is requeued in the same call. + false + + 32768 + false + false + LongTextArea + 5 + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/FinishedAt__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/FinishedAt__c.field-meta.xml new file mode 100644 index 0000000..1c7a92f --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/FinishedAt__c.field-meta.xml @@ -0,0 +1,10 @@ + + + FinishedAt__c + When the job settled: completed, failed after its last attempt, or skipped. Finished At minus Started At is the duration of the final attempt. + false + + false + false + DateTime + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/JobName__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/JobName__c.field-meta.xml new file mode 100644 index 0000000..e3bbd5f --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/JobName__c.field-meta.xml @@ -0,0 +1,12 @@ + + + JobName__c + Display name given with name(...) on the builder. Blank when the job was not named; show Class Name instead. + false + + 80 + false + false + Text + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/NextAttemptAt__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/NextAttemptAt__c.field-meta.xml new file mode 100644 index 0000000..624d3e8 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/NextAttemptAt__c.field-meta.xml @@ -0,0 +1,10 @@ + + + NextAttemptAt__c + When the next run of the job is due: a retry waiting for its next attempt, or a delayed job that is already enqueued. Blank for a delayed job still waiting for its turn, because its delay starts when the job before it finishes. Cleared when the job settles. + false + + false + false + DateTime + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/ParentCustomJobId__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/ParentCustomJobId__c.field-meta.xml new file mode 100644 index 0000000..929812b --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/ParentCustomJobId__c.field-meta.xml @@ -0,0 +1,12 @@ + + + ParentCustomJobId__c + For a finalizer, the Custom Job Id of the job it was attached to. Blank for every other job. + false + + 36 + false + false + Text + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/Priority__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/Priority__c.field-meta.xml new file mode 100644 index 0000000..a91c818 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/Priority__c.field-meta.xml @@ -0,0 +1,13 @@ + + + Priority__c + The priority set on the job, lower runs first. Blank when none was set. + false + + 9 + false + 0 + false + Number + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/StartedAt__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/StartedAt__c.field-meta.xml new file mode 100644 index 0000000..3c7187c --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/StartedAt__c.field-meta.xml @@ -0,0 +1,10 @@ + + + StartedAt__c + When work() started on the final attempt. Blank for a job that never ran. + false + + false + false + DateTime + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/Status__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/Status__c.field-meta.xml index 0f497de..8377b0c 100644 --- a/force-app/main/default/objects/AsyncResult__c/fields/Status__c.field-meta.xml +++ b/force-app/main/default/objects/AsyncResult__c/fields/Status__c.field-meta.xml @@ -1,7 +1,7 @@ Status__c - Lifecycle outcome of the job in the chain: whether it ran (completed/failed) or was skipped and why. + Lifecycle of the job in the chain. Enqueued means the job is waiting in the chain or running, written when Track Job Progress is on; the rest are outcomes: whether it ran (completed/failed) or was skipped and why. false false @@ -10,6 +10,11 @@ true false + + ENQUEUED + false + + COMPLETED false diff --git a/force-app/main/default/objects/AsyncResult__c/listViews/ByChain.listView-meta.xml b/force-app/main/default/objects/AsyncResult__c/listViews/ByChain.listView-meta.xml new file mode 100644 index 0000000..f2abcaa --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/listViews/ByChain.listView-meta.xml @@ -0,0 +1,14 @@ + + + ByChain + NAME + ChainName__c + ChainId__c + ChainSequence__c + JobName__c + ClassName__c + Status__c + StartedAt__c + Everything + + diff --git a/force-app/main/default/objects/AsyncResult__c/listViews/FailedLast7Days.listView-meta.xml b/force-app/main/default/objects/AsyncResult__c/listViews/FailedLast7Days.listView-meta.xml new file mode 100644 index 0000000..83e112a --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/listViews/FailedLast7Days.listView-meta.xml @@ -0,0 +1,25 @@ + + + FailedLast7Days + NAME + JobName__c + ClassName__c + Status__c + ChainName__c + StartedAt__c + FinishedAt__c + RequeueStatus__c + CREATED_DATE + Everything + + Status__c + equals + FAILED + + + CREATED_DATE + equals + LAST_N_DAYS:7 + + + diff --git a/force-app/main/default/objects/AsyncResult__c/listViews/InFlight.listView-meta.xml b/force-app/main/default/objects/AsyncResult__c/listViews/InFlight.listView-meta.xml new file mode 100644 index 0000000..020a534 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/listViews/InFlight.listView-meta.xml @@ -0,0 +1,20 @@ + + + InFlight + NAME + JobName__c + ClassName__c + Status__c + ChainName__c + StartedAt__c + FinishedAt__c + RequeueStatus__c + CREATED_DATE + Everything + + Status__c + equals + ENQUEUED + + + diff --git a/force-app/main/default/objects/AsyncResult__c/listViews/Replayable.listView-meta.xml b/force-app/main/default/objects/AsyncResult__c/listViews/Replayable.listView-meta.xml new file mode 100644 index 0000000..baf361e --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/listViews/Replayable.listView-meta.xml @@ -0,0 +1,20 @@ + + + Replayable + NAME + JobName__c + ClassName__c + Status__c + ChainName__c + StartedAt__c + FinishedAt__c + RequeueStatus__c + CREATED_DATE + Everything + + RequeueStatus__c + equals + Stored + + + diff --git a/force-app/main/default/objects/QueueableJobSetting__mdt/fields/MaxRecordLinks__c.field-meta.xml b/force-app/main/default/objects/QueueableJobSetting__mdt/fields/MaxRecordLinks__c.field-meta.xml new file mode 100644 index 0000000..2c6c2de --- /dev/null +++ b/force-app/main/default/objects/QueueableJobSetting__mdt/fields/MaxRecordLinks__c.field-meta.xml @@ -0,0 +1,12 @@ + + + MaxRecordLinks__c + How many records one job may be linked to through forRecord(...), forRecords(...) and Async.linkRecord(...). Blank means 5000, which is also the ceiling; a higher value is clamped and a warning is recorded on the job. Links beyond the limit are dropped with a warning in Retry History. + DeveloperControlled + + 9 + false + 0 + Number + false + diff --git a/force-app/main/default/objects/QueueableJobSetting__mdt/fields/TrackJobProgress__c.field-meta.xml b/force-app/main/default/objects/QueueableJobSetting__mdt/fields/TrackJobProgress__c.field-meta.xml new file mode 100644 index 0000000..32de9bc --- /dev/null +++ b/force-app/main/default/objects/QueueableJobSetting__mdt/fields/TrackJobProgress__c.field-meta.xml @@ -0,0 +1,25 @@ + + + TrackJobProgress__c + Create the result row when the job is enqueued instead of when it finishes, so a chain in flight shows which jobs wait, run and are done, with start and finish times. Yes implies Create Result for the matching job. Blank falls back to the All record; No on a job record overrides Yes on All. Needed for the progress bar on the record timeline; linked records and completed steps show without it. + DeveloperControlled + + false + Picklist + + true + + false + + Yes + false + + + + No + false + + + + + diff --git a/force-app/main/default/permissionsets/AsyncRequeueAccess.permissionset-meta.xml b/force-app/main/default/permissionsets/AsyncRequeueAccess.permissionset-meta.xml new file mode 100644 index 0000000..2c87e50 --- /dev/null +++ b/force-app/main/default/permissionsets/AsyncRequeueAccess.permissionset-meta.xml @@ -0,0 +1,10 @@ + + + + true + AsyncRequeue + + Grants the Async Requeue custom permission, which shows the requeue actions on the Async Jobs record page component. Assign together with Async Result Access. + false + + diff --git a/force-app/main/default/permissionsets/AsyncResultAccess.permissionset-meta.xml b/force-app/main/default/permissionsets/AsyncResultAccess.permissionset-meta.xml index 91e78b0..5bd79e9 100644 --- a/force-app/main/default/permissionsets/AsyncResultAccess.permissionset-meta.xml +++ b/force-app/main/default/permissionsets/AsyncResultAccess.permissionset-meta.xml @@ -1,8 +1,12 @@ - Read access to AsyncResult__c and all its fields, so admins and reports can see async job outcomes. The framework writes these records in system context and does not need this set. Job Payload is in this set and holds whatever data the job carried. + Read on AsyncResult__c, AsyncResultLink__c and all their fields, plus the Async Jobs component. The framework writes in system context and does not need this set. Job Payload is included and holds whatever data the job carried. false + + AsyncJobTimelineController + true + AsyncResult__c true @@ -12,6 +16,105 @@ true false + + AsyncResultLink__c + true + false + false + false + true + false + + + AsyncResult__c.ChainName__c + true + false + + + AsyncResult__c.ChainSequence__c + true + false + + + AsyncResult__c.ChunkPage__c + true + false + + + AsyncResult__c.DelayMinutes__c + true + false + + + AsyncResult__c.ChunkPages__c + true + false + + + AsyncResult__c.ChunkRunId__c + true + false + + + AsyncResult__c.Dependencies__c + true + false + + + AsyncResult__c.FinishedAt__c + true + false + + + AsyncResult__c.AddedByCustomJobId__c + true + false + + + AsyncResult__c.JobName__c + true + false + + + AsyncResult__c.NextAttemptAt__c + true + false + + + AsyncResult__c.ParentCustomJobId__c + true + false + + + AsyncResult__c.Priority__c + true + false + + + AsyncResult__c.StartedAt__c + true + false + + + AsyncResultLink__c.Key__c + true + false + + + AsyncResultLink__c.Source__c + true + false + + + AsyncResultLink__c.ObjectName__c + true + false + + + AsyncResultLink__c.UniqueKey__c + true + false + AsyncResult__c.ActualOutcome__c true diff --git a/force-app/main/default/staticresources/AsyncLibLogo.png b/force-app/main/default/staticresources/AsyncLibLogo.png new file mode 100644 index 0000000..812e5ae Binary files /dev/null and b/force-app/main/default/staticresources/AsyncLibLogo.png differ diff --git a/force-app/main/default/staticresources/AsyncLibLogo.resource-meta.xml b/force-app/main/default/staticresources/AsyncLibLogo.resource-meta.xml new file mode 100644 index 0000000..7b5972a --- /dev/null +++ b/force-app/main/default/staticresources/AsyncLibLogo.resource-meta.xml @@ -0,0 +1,6 @@ + + + Public + image/png + Async Lib logo, shown in the Async Jobs card header. + diff --git a/force-app/main/default/tabs/AsyncResult__c.tab-meta.xml b/force-app/main/default/tabs/AsyncResult__c.tab-meta.xml new file mode 100644 index 0000000..2cd8c7e --- /dev/null +++ b/force-app/main/default/tabs/AsyncResult__c.tab-meta.xml @@ -0,0 +1,6 @@ + + + true + Async job results written by Async Lib. + Custom67: Gears + diff --git a/jest-setup.js b/jest-setup.js new file mode 100644 index 0000000..87f9103 --- /dev/null +++ b/jest-setup.js @@ -0,0 +1,8 @@ +// jsdom's CSS parser predates @container, so it logs an error for the sequence stylesheet on +// every mount. The browser is where that rule is tested; the tests do not read styles. +const jsdomCssNoise = /Could not parse CSS stylesheet/; +const originalError = console.error; +console.error = (...args) => { + if (args.some(arg => jsdomCssNoise.test(arg?.message || String(arg)))) return; + originalError(...args); +}; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..43a5de1 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,17 @@ +const { jestConfig } = require('@salesforce/sfdx-lwc-jest/config'); + +module.exports = { + ...jestConfig, + setupFiles: [...(jestConfig.setupFiles || []), '/jest-setup.js'], + moduleNameMapper: { + '^lightning/modal$': '/dev/jest-mocks/lightning/modal', + '^c/asyncBadgeStyles$': + '/force-app/main/default/lwc/asyncBadgeStyles/asyncBadgeStyles.css' + }, + testPathIgnorePatterns: [ + '/node_modules/', + '/website/', + '/ticket/', + '/__tests__/data/' + ] +}; diff --git a/release-notes/v2.7.0.md b/release-notes/v2.7.0.md index a7862ea..12a8bf3 100644 --- a/release-notes/v2.7.0.md +++ b/release-notes/v2.7.0.md @@ -28,7 +28,7 @@ Async.batchable( .schedule(); ``` -Retention is explicit (no silent default that deletes data), and deletes run in system mode so the job works whatever the running user's permissions are. See [AsyncResult Cleanup](https://async.beyondthecloud.dev/explanations/asyncresult-cleanup) (#2). +Retention is explicit (no silent default that deletes data), and deletes run in system mode so the job works whatever the running user's permissions are. See [AsyncResult Cleanup](https://async.beyondthecloud.dev/features/asyncresult-cleanup) (#2). ## Improvements @@ -36,7 +36,7 @@ Retention is explicit (no silent default that deletes data), and deletes run in ## Documentation -- New [AsyncResult Cleanup](https://async.beyondthecloud.dev/explanations/asyncresult-cleanup) and [Expected Exceptions in Debug Logs](https://async.beyondthecloud.dev/explanations/expected-exceptions-in-debug-logs) explanation pages. +- New [AsyncResult Cleanup](https://async.beyondthecloud.dev/features/asyncresult-cleanup) and [Expected Exceptions in Debug Logs](https://async.beyondthecloud.dev/explanations/expected-exceptions-in-debug-logs) explanation pages. - New [Standard Apex vs Async Lib](https://async.beyondthecloud.dev/introduction/standard-apex-vs-async-lib) guide, plus docs search and per-class API examples. - Fixed the tip/warning callouts that weren't rendering on the Queueable API page. - [Documentation](https://async.beyondthecloud.dev) diff --git a/release-notes/v2.8.0.md b/release-notes/v2.8.0.md index d14f465..947205e 100644 --- a/release-notes/v2.8.0.md +++ b/release-notes/v2.8.0.md @@ -74,7 +74,7 @@ Any job can now be failed from a test without writing a job class that throws, m - New [Chunk API](https://async.beyondthecloud.dev/api/chunk) covering the API, chain placement, real cursor governor caps, carrying state between chunks and testing a run. - [onFinalFailure](https://async.beyondthecloud.dev/api/queueable#onfinalfailure) with a table of what each `RetryOutcome` means operationally. -- [Testing Async Jobs](https://async.beyondthecloud.dev/explanations/testing-async-jobs) gains patterns for chunk runs and for testing an `onFinalFailure` override. +- [Testing Async Jobs](https://async.beyondthecloud.dev/features/testing-async-jobs) gains patterns for chunk runs and for testing an `onFinalFailure` override. - [Standard Apex vs Async Lib](https://async.beyondthecloud.dev/introduction/standard-apex-vs-async-lib) gains side-by-side sections for large data sets and for reacting to a failed job. - SEO metadata, canonical URLs, `robots.txt` and `llms.txt`. diff --git a/release-notes/v3.0.0.md b/release-notes/v3.0.0.md index 9666476..2633885 100644 --- a/release-notes/v3.0.0.md +++ b/release-notes/v3.0.0.md @@ -102,7 +102,7 @@ meaning replays the old data under the new meaning, silently. If the fix changed fields, enqueue it fresh. Not permission-gated, like every other `Async` call; if you put it behind a button, check a custom permission in your controller first. -See [Requeue](https://async.beyondthecloud.dev/explanations/requeue). +See [Requeue](https://async.beyondthecloud.dev/features/requeue). ### Pluggable logger and job lifecycle events @@ -133,7 +133,7 @@ an unresolvable name degrades to no logging with the reason recorded. `info(key, value)` on the builder attaches metadata that arrives on every context, so alerts can be routed by team or package. `Async.FailureContext` gains `nextAttemptDelayMinutes`. See -[Logging](https://async.beyondthecloud.dev/explanations/logging). +[Logging](https://async.beyondthecloud.dev/features/logging). ### `restoreStateOnRetry()` and `restoreStateOnNextChunk()` @@ -209,7 +209,7 @@ even when `CreateResult__c` is off. `MaxRetries__c` above the cap, an unknown `BackoffStrategy__c`, or retry turned on for a job with no declared reset each threw from enqueue, and the `All` record reaches every job. Each now degrades to the safe behaviour and records why in `RetryHistory__c`. Mistakes written in Apex -still throw. See [Configuration Safety](https://async.beyondthecloud.dev/explanations/configuration-safety). +still throw. See [Configuration Safety](https://async.beyondthecloud.dev/features/configuration-safety). ### `className` is no longer recomputed on every hop @@ -225,9 +225,9 @@ lists for `DependsOnResult__c` and `RequeuedFrom__c`. ## Documentation - New: [Job State Between Runs](https://async.beyondthecloud.dev/explanations/job-state-between-runs), - [Logging](https://async.beyondthecloud.dev/explanations/logging), - [Configuration Safety](https://async.beyondthecloud.dev/explanations/configuration-safety), - [Requeue](https://async.beyondthecloud.dev/explanations/requeue). + [Logging](https://async.beyondthecloud.dev/features/logging), + [Configuration Safety](https://async.beyondthecloud.dev/features/configuration-safety), + [Requeue](https://async.beyondthecloud.dev/features/requeue). - New: [Async Lib for AI Agents](https://async.beyondthecloud.dev/ai-usage), the whole public surface on one page, also served as [`/llms.txt`](https://async.beyondthecloud.dev/llms.txt) and [`/llms-full.txt`](https://async.beyondthecloud.dev/llms-full.txt). diff --git a/release-notes/v3.1.0.md b/release-notes/v3.1.0.md index b8ab7dd..61c01d2 100644 --- a/release-notes/v3.1.0.md +++ b/release-notes/v3.1.0.md @@ -33,7 +33,7 @@ field paths as strings for parent fields (`'Owner.Email'`), or an `Async.BatchRe anything else. A scope that fails again stays open and can be requeued again. Recording follows `CreateResult__c`, on `All` or on a record named after the batch class. -[Batch Failures](https://async.beyondthecloud.dev/explanations/batch-failures) +[Batch Failures](https://async.beyondthecloud.dev/features/batch-failures) ### `Async.queueable().enqueue()` starts what other code chained @@ -76,7 +76,7 @@ dropped was already broken, but it will behave differently: the jobs run. - [Batch or Chunk?](https://async.beyondthecloud.dev/explanations/batch-or-chunk): why retry, finalizers and chaining stay Queueable features, why `Async.chunk` is the recommended path for new work over many records, and when a batch is still the right tool. -- [Batch Failures](https://async.beyondthecloud.dev/explanations/batch-failures) +- [Batch Failures](https://async.beyondthecloud.dev/features/batch-failures) - [Chaining Across Services](https://async.beyondthecloud.dev/explanations/chaining-across-services) - The chunk page no longer suggests a batch for runs longer than two days. A `QueryLocator` expires after two days as well. diff --git a/release-notes/v3.2.0.md b/release-notes/v3.2.0.md new file mode 100644 index 0000000..af67c82 --- /dev/null +++ b/release-notes/v3.2.0.md @@ -0,0 +1,131 @@ +# v3.2.0 + + + +# What's Changed + +## New Features + +### Every job a record went through, on the record page +a +Tag a job with the record it works on and give it a name: + +```apex +Async.queueable(new ReserveStockJob(lines)) + .name('Reserve stock') + .chainName('Order fulfilment pipeline') + .linkRecord(order) + .linkRecords(lines) + .chain(new CommissionSplitJob(order)).name('Commission split').linkRecord(order).retry(2) + .enqueue(); +``` + +Drop the new `Async Jobs` component on the Order record page. One card per chain, newest first, +with status, timing and failure reason per step, finalizers under their parent, chunk runs as one +step with a page strip, and a chain map with the dependency arcs. The card opens the first +running chain, the others collapse to one line with a dot per step, or a progress bar when the steps do not fit. The card polls +only while something is in flight and never uses Platform Events, so it costs nothing from the +org's delivery allocation. A step that appears while you watch slides in with a New badge. + +`linkRecord(...)` / `linkRecords(...)` take an `SObject`, an `Id`, a `List`, a `List` or +a `Set`. `linkExternalRecord('NetSuite', 'Customer', '4711')` links a record that lives in +another system, by key, so that system can query what ran for it. Inside `work()`, +`Async.linkRecord(...)`, `linkRecords(...)` and `linkExternalRecord(...)` add the records a job +discovers, without DML. A linked job always writes its result row. The links are rows of the new +`AsyncResultLink__c`: `Key__c` indexed, `Source__c` (`Salesforce`, or yours), `ObjectName__c`, +unique per result and key, with a page layout, a compact layout and a related list on the +result. A replay reads its links back from these rows. +[Job Timeline](https://async.beyondthecloud.dev/features/job-timeline) + +### See a chain while it runs + +`TrackJobProgress__c = Yes` on `QueueableJobSetting__mdt` writes the result row at enqueue, as +`ENQUEUED`, and updates it in place when the job settles, with `StartedAt__c`, `FinishedAt__c` and, +while retrying, `NextAttemptAt__c` and the attempt count. Nothing is written inside `work()`, so +jobs that call out are not affected. + +### Requeue failed steps in order, dependencies included + +`Async.requeue(ids)` replays rows in their original chain order whatever order the ids arrive in. +`dependsOn` now travels with the row: a dependency on another row of the same call is rebuilt +between the replays, one on a row whose outcome already satisfied it is dropped and reported in the +new `RequeueSummary.droppedDependencyByResultId`, and one that was not met skips the dependent with +a reason. `Requeue failed steps` on the record page does this for a whole chain, behind the new +`AsyncRequeue` custom permission. + +### `nextInChain()` + +From inside a running job, place a new job right after the current one, before everything already +waiting and ahead of any priority: + +```apex +public override void work() { + Invoice__c invoice = createInvoice(); + Async.queueable(new CalculateTaxJob(invoice)).nextInChain().enqueue(); +} +``` + +Finalizers of the current job still run first. Closes #68. + +## Improvements + +- `Async.JobContext` and `Async.FailureContext` carry `jobName` and `chainName`. +- A chain serialized before this version keeps running after the upgrade. +- An `ENQUEUED` row the cleanup batch removed while its job was delayed is written again when the + job settles. A placeholder whose `System.enqueueJob` failed is deleted in the same transaction. +- The timeline keeps polling through a failed check and stops after three in a row, sleeps until + the next scheduled retry when that is all that is in flight, watches for two minutes after a + requeue, and flags an idle card that has not checked for ten minutes. +- The card has a range menu, counts and filters chains across the whole range, pages in memory + through blocks of 50 chains, scrolls inside a configurable height, asks before a requeue, opens + linked records and the replay of a requeued step, and targets Experience Cloud record pages. +- The chain map is a pipeline of nodes with elbow connectors for dependencies, opened on the + first failed node. +- The card keeps a fixed height, fits its page size to it, refreshes in place, opens one chain by + itself and flags newcomers, or follows them with `Follow new runs`. The legend opens as a panel + over the list, so the card keeps its height. It carries the Async Lib logo and a footer naming + the version. +- `ChainSequence__c` always follows run order. A job with a priority takes its real place in the + chain, on the list, on the map and when requeued. +- A job added by a running job records who added it, whichever call added it. +- A delayed job shows as `Delayed`, with the time once it is enqueued and only its delay before that. The chain shows `DELAYED` before it starts and `RUNNING | DELAYED` after. +- Batch failures are handled 50 per delivery. A logger spending a DML per failure could hit the DML + limit on a large delivery, and that rolled back every failure record in it. +- `AsyncResult__c` Recently Viewed, lookup and search results show status, names and timing. +- `AsyncResult__c` has fourteen new fields: `JobName__c`, `ChainName__c`, `ChainSequence__c`, + `Priority__c`, `ParentCustomJobId__c`, `AddedByCustomJobId__c`, `ChunkRunId__c`, + `ChunkPage__c`, `ChunkPages__c`, `DelayMinutes__c`, `Dependencies__c`, `StartedAt__c`, `FinishedAt__c`, + `NextAttemptAt__c`; `Status__c` gains `ENQUEUED`. All on the layout and in `AsyncResultAccess`. +- `AsyncResult__c` ships with a tab, a Lightning record page, a compact layout and four list views: + Failed (last 7 days), In flight, Replayable, By chain. +- New permission set `AsyncRequeueAccess` with the `AsyncRequeue` custom permission. +- The `AsyncResult__c` tab uses the gears icon. +- Internal: `AsyncResult__c` and `AsyncResultLink__c` rows are written by the new + `AsyncResultWriter`, `QueueableJobSetting__mdt` is read through the new `QueueableJobSettings`; + `QueueableChain` keeps only the chain logic. No API change. +- `QueueableJobSetting__mdt.MaxRecordLinks__c` caps links per job, 5000 by default and at most. + +## Behaviour change to check + +Bulk `Async.requeue` used to replay rows in whatever order the query returned them. It now follows +`ChainSequence__c`. Rows written before 3.2.0 have no sequence and keep the old, undefined order. + +## Documentation + +- The site has a Features section (Job Timeline, Requeue, Batch Failures, Logging, Configuration + Safety, AsyncResult Cleanup, Testing Async Jobs); Explanations keeps the background pages. Old + `/explanations/...` links redirect. +- [Job Timeline](https://async.beyondthecloud.dev/features/job-timeline) +- [Requeue](https://async.beyondthecloud.dev/features/requeue): order and dependency rules. +- [For AI Agents](https://async.beyondthecloud.dev/ai-usage): the new surface, a recipe and the + gotchas. + +## Install + +``` +https://login.salesforce.com/packaging/installPackage.apexp?p0=04t... +``` + +Or deploy the source: [Installation](https://async.beyondthecloud.dev/introduction/installation). + + diff --git a/scripts/apex-headers.sh b/scripts/apex-headers.sh index 5a5448f..0d7b56f 100755 --- a/scripts/apex-headers.sh +++ b/scripts/apex-headers.sh @@ -1,5 +1,6 @@ #!/bin/bash -# The license header and version line every Apex file starts with. +# The license header and version line every Apex file starts with, plus the VERSION the +# Async Jobs card shows in its footer. # scripts/apex-headers.sh check fail on a missing header or a version other than sfdx-project.json's # scripts/apex-headers.sh bump 3.1.0 rewrite the version line in every file set -euo pipefail @@ -19,9 +20,15 @@ project_version() { jq -r '.packageDirectories[0].versionNumber' sfdx-project.json | sed 's/\.NEXT$//' } +LWC_VERSION_FILE='force-app/main/default/lwc/asyncJobTimelineModel/asyncJobTimelineModel.js' + check() { local expected="v$(project_version)" local failures=0 file + if ! grep -q "^export const VERSION = '$(project_version)';" "$LWC_VERSION_FILE"; then + echo "Expected VERSION = '$(project_version)' in $LWC_VERSION_FILE (the card footer shows it)" >&2 + failures=$((failures + 1)) + fi while IFS= read -r file; do if [ "$(sed -n 1p "$file")" != '/**' ] || [ "$(sed -n 2p "$file")" != "$COPYRIGHT_LINE" ] || @@ -51,6 +58,8 @@ bump() { sed -i.bak "${VERSION_LINE_NUMBER}s/^ \* v[0-9]*\.[0-9]*\.[0-9]*$/ * v$version/" "$file" rm -f "$file.bak" done < <(apex_files) + sed -i.bak "s/^export const VERSION = '[0-9]*\.[0-9]*\.[0-9]*';/export const VERSION = '$version';/" "$LWC_VERSION_FILE" + rm -f "$LWC_VERSION_FILE.bak" } case "${1:-}" in diff --git a/scripts/api-surface.sh b/scripts/api-surface.sh index d439732..d65e286 100644 --- a/scripts/api-surface.sh +++ b/scripts/api-surface.sh @@ -70,7 +70,7 @@ revert_internal_wiring() { # Enqueue-time snapshot plumbing. QueueableChain, QueueableManager and ChunkJob call these # across class boundaries so they cannot be protected, but a subscriber has no use for them - # and a global member can never be withdrawn. See docs/api-evolution.md. + # and a global member can never be withdrawn. See dev/docs/api-evolution.md. api_surface_sed 's/global void captureEnqueuedState(/public void captureEnqueuedState(/g' \ "force-app/main/default/classes/queue/QueueableJob.cls" api_surface_sed 's/global Boolean canRestoreEnqueuedState(/public Boolean canRestoreEnqueuedState(/g' \ @@ -93,6 +93,24 @@ revert_internal_wiring() { api_surface_sed 's/global Id requeuedFromResultId;/public Id requeuedFromResultId;/g' \ "force-app/main/default/classes/queue/QueueableJob.cls" + # Timeline plumbing. QueueableChain sets these across class boundaries; a subscriber reads + # the same facts from AsyncResult__c. jobName, chainName and links stay global on purpose: + # a subscriber job may read what it was given on the builder. + api_surface_sed 's/global Boolean nextInChain = false;/public Boolean nextInChain = false;/g' \ + "force-app/main/default/classes/queue/QueueableJob.cls" + api_surface_sed 's/global String addedByCustomJobId;/public String addedByCustomJobId;/g' \ + "force-app/main/default/classes/queue/QueueableJob.cls" + api_surface_sed 's/global Datetime startedAt;/public Datetime startedAt;/g' \ + "force-app/main/default/classes/queue/QueueableJob.cls" + api_surface_sed 's/global Integer recordLinksWritten = 0;/public Integer recordLinksWritten = 0;/g' \ + "force-app/main/default/classes/queue/QueueableJob.cls" + api_surface_sed 's/global void linkRecords(/public void linkRecords(/g' \ + "force-app/main/default/classes/queue/QueueableJob.cls" + api_surface_sed 's/global void linkExternalRecords(/public void linkExternalRecords(/g' \ + "force-app/main/default/classes/queue/QueueableJob.cls" + api_surface_sed 's/global void addLinks(/public void addLinks(/g' \ + "force-app/main/default/classes/queue/QueueableJob.cls" + api_surface_sed 's/global ChunkRun getRun(/public ChunkRun getRun(/g' \ "force-app/main/default/classes/queue/ChunkJob.cls" diff --git a/scripts/pmd.sh b/scripts/pmd.sh index f9cbc95..5d083ca 100755 --- a/scripts/pmd.sh +++ b/scripts/pmd.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Runs PMD over the Apex source with pmd/ruleset.xml. +# Runs PMD over the Apex source with dev/pmd/ruleset.xml. # # npm run pmd report only, always exits 0 # npm run pmd:verify exits 4 when anything is reported (the CI gate) @@ -11,7 +11,7 @@ set -euo pipefail cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PMD_VERSION="7.5.0" -RULESET="pmd/ruleset.xml" +RULESET="dev/pmd/ruleset.xml" SOURCE_DIR="force-app" DIST_DIR=".pmd-dist" diff --git a/scripts/release.sh b/scripts/release.sh index 94866ba..defa371 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -5,7 +5,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$PROJECT_ROOT" # The CLI's update banner goes to stderr; the `--json 2>&1` calls below would feed -# it to jq, which then fails to parse. Suppress it (same guard as package-tests/run.sh). +# it to jq, which then fails to parse. Suppress it (same guard as dev/package-tests/run.sh). export SF_SKIP_NEW_VERSION_CHECK=true # sf package version create resolves the dev hub from config, which is not set in diff --git a/scripts/verify-package-install.sh b/scripts/verify-package-install.sh index 8730379..fe9abfd 100755 --- a/scripts/verify-package-install.sh +++ b/scripts/verify-package-install.sh @@ -66,12 +66,12 @@ echo -e "${GREEN}Installed${NC}" echo -e "${BLUE}=== Deploying consumer classes ===${NC}" sf project deploy start \ - --source-dir "$PROJECT_ROOT/package-tests/consumer-app/force-app" \ + --source-dir "$PROJECT_ROOT/dev/package-tests/consumer-app/force-app" \ --target-org "$ORG_ALIAS" \ --wait 20 >/dev/null echo -e "${GREEN}Deployed${NC}" -TEST_CLASSES=$(grep -l '^@IsTest' "$PROJECT_ROOT"/package-tests/consumer-app/force-app/main/default/classes/*.cls | +TEST_CLASSES=$(grep -l '^@IsTest' "$PROJECT_ROOT"/dev/package-tests/consumer-app/force-app/main/default/classes/*.cls | while IFS= read -r f; do basename "$f" .cls; done) if [ -z "$TEST_CLASSES" ]; then diff --git a/vercel.json b/vercel.json index ef48b34..1eff155 100644 --- a/vercel.json +++ b/vercel.json @@ -2,5 +2,112 @@ "buildCommand": "npm run docs:build", "outputDirectory": "website/.vitepress/dist", "installCommand": "npm install", - "cleanUrls": true + "cleanUrls": true, + "redirects": [ + { + "source": "/explanations/job-timeline", + "destination": "/features/job-timeline", + "statusCode": 301 + }, + { + "source": "/explanations/job-timeline.md", + "destination": "/features/job-timeline.md", + "statusCode": 301 + }, + { + "source": "/explanations/job-timeline.html", + "destination": "/features/job-timeline", + "statusCode": 301 + }, + { + "source": "/explanations/requeue", + "destination": "/features/requeue", + "statusCode": 301 + }, + { + "source": "/explanations/requeue.md", + "destination": "/features/requeue.md", + "statusCode": 301 + }, + { + "source": "/explanations/requeue.html", + "destination": "/features/requeue", + "statusCode": 301 + }, + { + "source": "/explanations/batch-failures", + "destination": "/features/batch-failures", + "statusCode": 301 + }, + { + "source": "/explanations/batch-failures.md", + "destination": "/features/batch-failures.md", + "statusCode": 301 + }, + { + "source": "/explanations/batch-failures.html", + "destination": "/features/batch-failures", + "statusCode": 301 + }, + { + "source": "/explanations/logging", + "destination": "/features/logging", + "statusCode": 301 + }, + { + "source": "/explanations/logging.md", + "destination": "/features/logging.md", + "statusCode": 301 + }, + { + "source": "/explanations/logging.html", + "destination": "/features/logging", + "statusCode": 301 + }, + { + "source": "/explanations/configuration-safety", + "destination": "/features/configuration-safety", + "statusCode": 301 + }, + { + "source": "/explanations/configuration-safety.md", + "destination": "/features/configuration-safety.md", + "statusCode": 301 + }, + { + "source": "/explanations/configuration-safety.html", + "destination": "/features/configuration-safety", + "statusCode": 301 + }, + { + "source": "/explanations/asyncresult-cleanup", + "destination": "/features/asyncresult-cleanup", + "statusCode": 301 + }, + { + "source": "/explanations/asyncresult-cleanup.md", + "destination": "/features/asyncresult-cleanup.md", + "statusCode": 301 + }, + { + "source": "/explanations/asyncresult-cleanup.html", + "destination": "/features/asyncresult-cleanup", + "statusCode": 301 + }, + { + "source": "/explanations/testing-async-jobs", + "destination": "/features/testing-async-jobs", + "statusCode": 301 + }, + { + "source": "/explanations/testing-async-jobs.md", + "destination": "/features/testing-async-jobs.md", + "statusCode": 301 + }, + { + "source": "/explanations/testing-async-jobs.html", + "destination": "/features/testing-async-jobs", + "statusCode": 301 + } + ] } diff --git a/website/.vitepress/config.mts b/website/.vitepress/config.mts index 03d09d5..87cfc9d 100644 --- a/website/.vitepress/config.mts +++ b/website/.vitepress/config.mts @@ -171,6 +171,25 @@ export default defineConfig({ { text: 'AsyncMock', link: '/api/async-mock' } ] }, + { + text: 'Features', + collapsed: false, + items: [ + { text: 'Job Timeline', link: '/features/job-timeline' }, + { text: 'Requeue', link: '/features/requeue' }, + { text: 'Batch Failures', link: '/features/batch-failures' }, + { text: 'Logging', link: '/features/logging' }, + { + text: 'Configuration Safety', + link: '/features/configuration-safety' + }, + { + text: 'AsyncResult Cleanup', + link: '/features/asyncresult-cleanup' + }, + { text: 'Testing Async Jobs', link: '/features/testing-async-jobs' } + ] + }, { text: 'Explanations', collapsed: false, @@ -183,18 +202,11 @@ export default defineConfig({ text: 'Job State Between Runs', link: '/explanations/job-state-between-runs' }, - { - text: 'Configuration Safety', - link: '/explanations/configuration-safety' - }, - { text: 'Logging', link: '/explanations/logging' }, - { text: 'Requeue', link: '/explanations/requeue' }, { text: 'Chaining Across Services', link: '/explanations/chaining-across-services' }, { text: 'Batch or Chunk?', link: '/explanations/batch-or-chunk' }, - { text: 'Batch Failures', link: '/explanations/batch-failures' }, { text: 'Job Cloning', link: '/explanations/job-cloning' }, { text: 'Deep Clone in Packages', @@ -204,11 +216,6 @@ export default defineConfig({ text: 'Failures and the Chain', link: '/explanations/failures-and-the-chain' }, - { text: 'Testing Async Jobs', link: '/explanations/testing-async-jobs' }, - { - text: 'AsyncResult Cleanup', - link: '/explanations/asyncresult-cleanup' - }, { text: 'Expected Exceptions in Debug Logs', link: '/explanations/expected-exceptions-in-debug-logs' diff --git a/website/.vitepress/theme/components/JobTimelineDemo.vue b/website/.vitepress/theme/components/JobTimelineDemo.vue new file mode 100644 index 0000000..b5ed297 --- /dev/null +++ b/website/.vitepress/theme/components/JobTimelineDemo.vue @@ -0,0 +1,654 @@ + + + + + diff --git a/website/.vitepress/theme/custom.css b/website/.vitepress/theme/custom.css index 315ef8c..8edd16b 100644 --- a/website/.vitepress/theme/custom.css +++ b/website/.vitepress/theme/custom.css @@ -12,4 +12,33 @@ .VPHome { margin-bottom: 0 !important; -} \ No newline at end of file +} + +.VPHero.has-image .image { + order: 2; + margin: 32px 0 0; +} + +.VPHero.has-image .image-container { + width: 100%; + height: auto; + transform: none; +} + +.VPHero.has-image .image-bg { + z-index: 0; +} + +@media (min-width: 960px) { + .VPHero.has-image .image { + margin: 0; + } + + .VPHero.has-image .main { + max-width: 520px; + } +} + +.landing-section { + margin-top: 56px; +} diff --git a/website/.vitepress/theme/index.js b/website/.vitepress/theme/index.js index 582b2ab..7d626b7 100644 --- a/website/.vitepress/theme/index.js +++ b/website/.vitepress/theme/index.js @@ -1,12 +1,14 @@ import { h } from 'vue'; import DefaultTheme from 'vitepress/theme'; import BTCFooter from './components/BTCFooter.vue'; +import JobTimelineDemo from './components/JobTimelineDemo.vue'; import './custom.css'; export default { extends: DefaultTheme, Layout() { return h(DefaultTheme.Layout, null, { + 'home-hero-image': () => h(JobTimelineDemo), 'layout-bottom': () => h(BTCFooter, { context: 'async-lib' }) }); }, diff --git a/website/ai-usage.md b/website/ai-usage.md index 33416b5..73d9d5b 100644 --- a/website/ai-usage.md +++ b/website/ai-usage.md @@ -27,7 +27,9 @@ Names below are for a source deploy. On a packaged install prefix every class wi | `Async.after(Result r)` / `Async.after(String customJobId)` / `Async.afterPrevious()` | `Async.Dependency` | the target of `dependsOn(...)`, finished with `.succeeded()`, `.failed()` or `.finished()` | | `Async.stopChain()` | | inside a job or finalizer: skip every remaining job in the chain | | `Async.skipJob(String customJobId)` | | inside a job or finalizer: skip one job and its finalizers | -| `Async.requeue(Id resultId)` / `Async.requeue(Set resultIds)` | `Async.RequeueSummary` | replay failed jobs from their `AsyncResult__c` records | +| `Async.requeue(Id resultId)` / `Async.requeue(Set resultIds)` | `Async.RequeueSummary` | replay failed jobs from their `AsyncResult__c` records, in chain order, with `dependsOn` rebuilt between rows of the same call | +| `Async.linkRecord(SObject)` / `linkRecord(Id)` / `linkRecords(List)` / `linkRecords(List)` / `linkRecords(Set)` | | inside `work()`: link the running job to records it discovered; no DML, written when the job settles | +| `Async.linkExternalRecord(String source, String objectName, String key)` / `linkExternalRecords(List)` | | inside `work()`: link the running job to a record in another system by its key; `source` and `objectName` are stored as given, blank stays blank | | `Async.requeueBatchFailures(Object batch, Id batchJobId, List fieldsToReload)` / `(batch, batchJobId, List fieldsToReload)` / `(batch, batchJobId, Async.BatchRecordLoader loader)` | `Async.RequeueSummary` | run the failed scopes of a batch again through its own `execute()`; the third argument is required and says how to reload the records | | `Async.getQueueableJobContext()` | `Async.QueueableJobContext` | inside a job: the current job, `QueueableContext`, `FinalizerContext` | | `Async.getCurrentQueueableChainState()` | `Async.QueueableChainState` | every job in the chain and what runs next | @@ -57,6 +59,10 @@ normal transaction starts a separate chain: [Chaining Across Services](/explanat | `restoreStateOnRetry()` | replay every attempt from the job as it was at enqueue | | `deepClone()` | copy collections and objects, not just references, when cloning the job | | `dependsOn(Async.Dependency)` | skip this job unless the target had that outcome | +| `name(String)` / `chainName(String)` | display names on the record page timeline and `AsyncResult__c`; `chainName` applies to the whole chain, last one wins | +| `linkRecord(SObject)` / `linkRecord(Id)` / `linkRecords(List)` / `linkRecords(List)` / `linkRecords(Set)` | link the job to the Salesforce records it works on; forces a result row; capped by `MaxRecordLinks__c` | +| `linkExternalRecord(String source, String objectName, String key)` / `linkExternalRecords(List)` | link the job to a record in another system by its key, e.g. `linkExternalRecord('NetSuite', 'Customer', '4711')`; only `key` is required | +| `nextInChain()` | inside `work()` only: run this job right after the current one, before everything waiting; not with `priority()` | | `info(String key, String value)` / `info(Map)` | metadata that arrives on every lifecycle context | | `mockId(String)` | key for `AsyncMock` in tests | | `chain(QueueableJob next)` | add this job to the chain and hold `next` | @@ -68,7 +74,8 @@ normal transaction starts a separate chain: [Chaining Across Services](/explanat ### ChunkBuilder -Everything from `QueueableBuilder` that makes sense for a run, plus: +Everything from `QueueableBuilder` that makes sense for a run (`name`, `chainName`, `linkRecord(s)` +and `nextInChain` included), plus: | Method | What it does | | ------ | ------------ | @@ -178,9 +185,10 @@ Inside `work()` of a `ChunkJob`, `getRun()` gives `currentPageNumber()`, `hasRem | `Async.Result` | `salesforceJobId`, `customJobId`, `asyncType`, `job`, `queueableChainState` | | `Async.QueueableChainState` | `jobs`, `nextSalesforceJobId`, `nextCustomJobId`, `enqueueType` | | `Async.QueueableJobContext` | `currentJob`, `queueableCtx`, `finalizerCtx` | -| `Async.JobContext` | `customJobId`, `className`, `salesforceJobId`, `chainId`, `priority`, `retryAttempt`, `info` | -| `Async.FailureContext` | `retryOutcome`, `failure` (`type`, `message`, `stackTrace`), `customJobId`, `className`, `retryAttempt`, `maxRetries`, `retryHistory`, `nextAttemptDelayMinutes`, `info`, `asyncType`, `salesforceJobId`, and for a batch failure `batchPhase`, `batchScope`, `batchScopeTruncated` | -| `Async.RequeueSummary` | `requeued`, `skipReasonByResultId`, `enqueueResult` | +| `Async.JobContext` | `customJobId`, `className`, `salesforceJobId`, `chainId`, `priority`, `retryAttempt`, `info`, `jobName`, `chainName` | +| `Async.FailureContext` | `retryOutcome`, `failure` (`type`, `message`, `stackTrace`), `customJobId`, `className`, `retryAttempt`, `maxRetries`, `retryHistory`, `nextAttemptDelayMinutes`, `info`, `asyncType`, `salesforceJobId`, and for a batch failure `batchPhase`, `batchScope`, `batchScopeTruncated`; `jobName`, `chainName` | +| `Async.RequeueSummary` | `requeued`, `skipReasonByResultId`, `droppedDependencyByResultId`, `enqueueResult` | +| `Async.RecordLink` | `source`, `objectName`, `key`; built with `new Async.RecordLink(source, objectName, key)`, nothing is derived from the key | | `Async.Outcome` | `SUCCESS`, `FAILURE`, `COMPLETED` | | `Async.RetryOutcome` | `NOT_CONFIGURED`, `NOT_RETRYABLE`, `EXHAUSTED` | | `Async.AsyncType` | `QUEUEABLE`, `BATCHABLE`, `SCHEDULABLE` | @@ -198,21 +206,30 @@ applies to that job. Wrong values degrade and record why, they never stop a job. | `BackoffStrategy__c` | Text | `FIXED`, `EXPONENTIAL`, `EXPONENTIAL_JITTER` | | `BackoffBaseMinutes__c` | Number | base for the strategy | | `RetryableExceptions__c` | Text | comma-separated exception type names | -| `LoggerClass__c` | Text | a `global` class implementing the lifecycle interfaces; batch failures arrive on `OnJobFailed` | +| `LoggerClass__c` | Text | a `global` class implementing the lifecycle interfaces; batch failures arrive on `OnJobFailed`, at most 50 per transaction, so keep it to about 3 DML per call | | `StoreJobPayload__c` | Picklist `Yes`/`No` | store a snapshot for `requeue()`; `No` on a job beats `Yes` on `All` | | `JobSerializerClass__c` | Text | a `global` `Async.JobSerializer`, packaged installs only | +| `TrackJobProgress__c` | Picklist `Yes`/`No` | write the row at enqueue as `ENQUEUED` and update it in place, with `StartedAt__c` / `FinishedAt__c`; implies a row for every outcome; `No` on a job beats `Yes` on `All` | +| `MaxRecordLinks__c` | Number | links stored per job through `linkRecord(s)` and `linkExternalRecord(s)`, on the builder or inside `work()`; blank is 5000, which is also the ceiling | In tests, inject them: `AsyncMock.jobSettings(new List{ ... })`. ## `AsyncResult__c` -One row per job, written after its last attempt, when `CreateResult__c` is on or a payload is -stored. A batch writes one row per failed scope and nothing for a successful one. Read access -through the `AsyncResultAccess` permission set. +One row per job, written after its last attempt, when `CreateResult__c` is on, a payload is +stored, or the job is linked to a record; written at enqueue instead when `TrackJobProgress__c` is +`Yes`. A batch writes one row per failed scope and nothing for a successful one. Read access through +the `AsyncResultAccess` permission set, which also covers `AsyncResultLink__c` (one row per result +and linked record: `Key__c` indexed, `Source__c` `Salesforce` or whatever `linkExternalRecord` +was given, `ObjectName__c`). The requeue actions on the record page need the +`AsyncRequeue` custom permission from the `AsyncRequeueAccess` permission set. | Field | Holds | | ----- | ----- | -| `Status__c` | `COMPLETED`, `FAILED`, `SKIPPED_DEPENDENCY`, `SKIPPED_CHAIN_STOPPED`, `SKIPPED_CHUNK_STOPPED`, `SKIPPED_EXPLICIT`, `SKIPPED_DISABLED`, `FRAMEWORK_ERROR` | +| `Status__c` | `ENQUEUED`, `COMPLETED`, `FAILED`, `SKIPPED_DEPENDENCY`, `SKIPPED_CHAIN_STOPPED`, `SKIPPED_CHUNK_STOPPED`, `SKIPPED_EXPLICIT`, `SKIPPED_DISABLED`, `FRAMEWORK_ERROR` | +| `JobName__c`, `ChainName__c`, `ChainSequence__c`, `Priority__c` | names, position in the chain in run order, priority | +| `StartedAt__c`, `FinishedAt__c`, `NextAttemptAt__c`, `DelayMinutes__c` | timing; `NextAttemptAt__c` is the next retry or the start of an enqueued delayed job, `DelayMinutes__c` the delay set on the job; `Running` and `Delayed` are not stored, the record page derives them | +| `ParentCustomJobId__c`, `AddedByCustomJobId__c`, `ChunkRunId__c`, `ChunkPage__c`, `ChunkPages__c`, `Dependencies__c` | finalizer parent, the running job that added this one, chunk run position, `dependsOn` rules as JSON | | `ClassName__c`, `CustomJobId__c`, `SalesforceJobId__c`, `ChainId__c` | identity | | `Result__c`, `ExceptionType__c`, `ExceptionMessage__c` | outcome | | `RetryAttempts__c`, `RetryHistory__c` | one line per attempt, plus configuration warnings | @@ -353,6 +370,33 @@ Id jobId = Async.batchable(new AccountSyncBatch()).scopeSize(200).execute().sale Async.requeueBatchFailures(new AccountSyncBatch(), jobId, new List{ Account.Name }); ``` +### A pipeline you can watch on the record page + +```apex +Async.queueable(new ReserveStockJob(lines)) + .name('Reserve stock') + .chainName('Order fulfilment pipeline') + .linkRecord(order) + .linkRecords(lines) + .chain(new CommissionSplitJob(order)).name('Commission split').linkRecord(order).retry(2) + .chain(new AccountRollupJob(order.AccountId)) + .name('Recalculate account rollups') + .linkRecord(order) + .dependsOn(Async.afterPrevious().succeeded()) + .enqueue(); + +public class CommissionSplitJob extends QueueableJob implements Async.Retryable { + public override void work() { + Account parent = [SELECT Id FROM Account WHERE Id = :order.AccountId]; + Async.linkRecord(parent); + Async.queueable(new SplitAuditJob(order)).name('Audit split').nextInChain().enqueue(); + } + public void resetBeforeRetry(Integer attempt) {} +} +// QueueableJobSetting__mdt.TrackJobProgress__c = Yes on All shows the chain while it runs. +// Place the "Async Jobs" component on the Order record page. +``` + ### Test a job ```apex @@ -394,11 +438,30 @@ Things that read as bugs and are not, and things agents get wrong on the first t [Deep Clone in Packages](/explanations/deep-clone-in-packages). - **Inside a `QueueableJob` subclass write `Async.Backoff.exponential(1)`, not `Backoff.exponential(1)`.** The inherited `backoff` field shadows the type there. -- **Result rows are opt-in.** Nothing is written unless `CreateResult__c` is on, or a payload is - stored and the job failed or was skipped. Do not query `AsyncResult__c` and expect a row. +- **Result rows are opt-in.** Nothing is written unless `CreateResult__c` or `TrackJobProgress__c` + is on, a payload is stored and the job failed or was skipped, or the job is linked to a record + with `linkRecord(s)` / `linkExternalRecord(s)`. Do not query `AsyncResult__c` and expect a row + otherwise. +- **Linked records are rows, not context fields.** `JobContext` and `FailureContext` have no + `links`; query `AsyncResultLink__c` by `AsyncResult__r.CustomJobId__c`. On a chunk run + the builder's `linkRecords()` links the first page only; each page links its own in `work()`. +- **`Source__c` is never guessed.** `linkRecord(s)` takes an `Id`, so it stamps `Salesforce` and + the object name. `linkExternalRecord(s)` stores `source` and `objectName` exactly as given; a + key that happens to look like an Id is not promoted. Same key under two sources is two links. +- **`nextInChain()`, `Async.linkRecord(s)` and `Async.linkExternalRecord(s)` only work from inside a running job.** All throw + anywhere else. `nextInChain()` also refuses `priority()`: a job placed next runs before any + priority. [Job Timeline](/features/job-timeline). +- **`Running` is never written to `AsyncResult__c`.** With `TrackJobProgress__c` a row is `ENQUEUED` + until it settles; the record page reads `AsyncApexJob.Status` to tell queued from running. +- **`requeue()` keeps chain order and rebuilds `dependsOn` only between rows of the same call.** + A target outside the call that already satisfied the dependency is dropped and reported in + `droppedDependencyByResultId`; one that did not skips the dependent with a reason. +- **The record page component polls, it does not subscribe.** No Platform Events, so nothing + counts against the org's delivery allocation; it checks every 3 s while a job runs and stops when + nothing is in flight. - **`requeue()` replays data, not intent.** The payload is the job as it was enqueued, rebuilt by the class as it is now. If the fix renamed a field or changed what one means, enqueue fresh. - [Requeue](/explanations/requeue). + [Requeue](/features/requeue). - **Anything registered by name in Custom Metadata is `global` on a packaged install.** `LoggerClass__c`, `JobSerializerClass__c`. Class only, methods stay `public`. - **A `ChunkJob` is not a batch.** One page per transaction, in sequence, inside the chain, with @@ -415,10 +478,10 @@ Things that read as bugs and are not, and things agents get wrong on the first t - **A batch failure is recorded only if the batch implements `Database.RaisesPlatformEvents`.** The row is written by a platform event trigger about a second after the scope fails, so the last one may not be there yet inside the batch's own `finish()`. In a test, catch what `Test.stopTest()` rethrows and - call `Test.getEventBus().deliver()`. [Batch Failures](/explanations/batch-failures). + call `Test.getEventBus().deliver()`. [Batch Failures](/features/batch-failures). - **`requeueBatchFailures` needs to be told what to reload.** A failure stores ids only. Pass the fields `execute()` reads, an empty list for `Id` only, or an `Async.BatchRecordLoader`. `null` throws. It does not call `finish()` and does not retry on its own. A loader must be a saved class, not one declared inside an anonymous Apex script. - **A logger hears batch failures as the Automated Process user.** Log there; do not enqueue work. -- **`delay()` and `asyncOptions()` are exclusive**, and `delay` tops out at 10 minutes. +- **`delay()` and `asyncOptions()` are exclusive**, and `delay` tops out at 10 minutes. The delay starts when the job is enqueued, so in a chain that is when the job before it finishes, and `NextAttemptAt__c` stays blank until then. diff --git a/website/api/async-mock.md b/website/api/async-mock.md index 98f9e21..21a70b0 100644 --- a/website/api/async-mock.md +++ b/website/api/async-mock.md @@ -55,7 +55,7 @@ static void shouldMockFinalizerContext() { For finalizer mocking, the `mockId` must be set on the finalizer itself (via `attachFinalizer()` inside `work()`), not on the parent job. ::: -For testing patterns and best practices, see [Testing Async Jobs](/explanations/testing-async-jobs). +For testing patterns and best practices, see [Testing Async Jobs](/features/testing-async-jobs). ## Methods diff --git a/website/api/batchable.md b/website/api/batchable.md index d29cc9e..d64ef36 100644 --- a/website/api/batchable.md +++ b/website/api/batchable.md @@ -53,7 +53,7 @@ when a batch is still the right tool. Async Lib ships `AsyncResultCleanupBatch` for deleting old `AsyncResult__c` records, with separate retention for failed results and the rest. See -[AsyncResult Cleanup](/explanations/asyncresult-cleanup). +[AsyncResult Cleanup](/features/asyncresult-cleanup). ::: @@ -178,7 +178,7 @@ Returns `result.salesforceJobId` containing the Salesforce Job Id. A batch that implements `Database.RaisesPlatformEvents` has every failed scope recorded on `AsyncResult__c` and reported to your logger, governor limit errors included. The full story is on -[Batch Failures](/explanations/batch-failures). +[Batch Failures](/features/batch-failures). ```apex public class MyBatchJob implements Database.Batchable, Database.RaisesPlatformEvents { diff --git a/website/api/chunk.md b/website/api/chunk.md index 8ad1d7e..920fc20 100644 --- a/website/api/chunk.md +++ b/website/api/chunk.md @@ -373,7 +373,23 @@ Cursor expiry, the daily cursor allocation, and the 2,000 record fetch ceiling a platform behaviours. They cannot be reproduced in a test, and faking them would test the fake rather than the framework. -See [Testing Async Jobs](/explanations/testing-async-jobs) for worked examples. +See [Testing Async Jobs](/features/testing-async-jobs) for worked examples. + +## Inside a page + +A page is a running `QueueableJob`, so everything under +[Context](/api/queueable#context) and [Chain control](/api/queueable#chain-control) on the +Queueable page works inside `work(List chunk)` the same way: + +| Call | On a page | +| ---- | --------- | +| `Async.getQueueableJobContext()` | the context of the page that is running, `currentJob` is the page | +| `Async.stopChain()` | stops the whole chain, the remaining pages included; the run records `SKIPPED_CHAIN_STOPPED` | +| `Async.skipJob(customJobId)` | skips a job waiting after the run | +| `Async.linkRecord(s)`, `Async.linkExternalRecord(s)` | links this page's records; the builder's links went on the first page | +| `Async.queueable(...).nextInChain()` | runs right after this page, before the next one | +| `Async.queueable(...).priority(n).chain()` | a higher priority than the run jumps the remaining pages, see [Placing a run in a chain](#placing-a-run-in-a-chain) | +| `Async.requeue(...)` | from anywhere, not tied to the page; a chunk page itself stores no payload, so it is never replayable | ## Methods @@ -625,6 +641,113 @@ This only affects what you can read from inside the run. Every page writes its o ChunkBuilder keepChunkPages(); ``` +#### name / chainName + +Display names for the record page timeline and `AsyncResult__c`. `name` labels the run, each +page shows it with its page number; `chainName` applies to the whole chain, the last one set +wins. Without a name the class name is shown. See [Job Timeline](/features/job-timeline). + +**Signature** + +```apex +ChunkBuilder name(String jobName); +ChunkBuilder chainName(String chainName); +``` + +**Example** + +```apex +Async.chunk(new RepriceLinesJob(), ChunkSource.ofIds(lineIds)) + .name('Reprice lines') + .chainName('Order fulfilment pipeline') + .enqueue(); +``` + +#### linkRecord / linkRecords + +Links the run to the Salesforce record(s) it works on. The links are written once, on the +first page, with `Source__c` `Salesforce` and `ObjectName__c` from the Id. Each page links +its own records with `Async.linkRecords(...)` inside `work(...)`. A run shows as one step on +the record page timeline, with a page strip. Capped by +`QueueableJobSetting__mdt.MaxRecordLinks__c` like a queueable. + +**Signature** + +```apex +ChunkBuilder linkRecord(SObject record); +ChunkBuilder linkRecord(Id recordId); +ChunkBuilder linkRecords(List records); +ChunkBuilder linkRecords(List recordIds); +ChunkBuilder linkRecords(Set recordIds); +``` + +**Example** + +```apex +Async.chunk(new RepriceLinesJob(), ChunkSource.ofIds(lineIds)) + .name('Reprice lines') + .linkRecord(order) + .enqueue(); + +public class RepriceLinesJob extends ChunkJob implements Async.ChunkResettable { + public override void work(List page) { + Async.linkRecords(page); + } + + public void resetBeforeNextChunk(Integer pageNumber) { + } +} +``` + +#### linkExternalRecord / linkExternalRecords + +Links the run to a record in another system by its key, written on the first page like the +builder's record links. `source` and `objectName` are stored as given, blank stays blank; +only `key` is required. Inside `work(...)`, `Async.linkExternalRecord(...)` does the same for +a page. + +**Signature** + +```apex +ChunkBuilder linkExternalRecord(String source, String objectName, String key); +ChunkBuilder linkExternalRecords(List links); +``` + +**Example** + +```apex +Async.chunk(new SyncCustomersJob(), ChunkSource.ofIds(accountIds)) + .name('Sync customers') + .linkExternalRecord('NetSuite', 'SyncBatch', batchNumber) + .enqueue(); +``` + +#### nextInChain + +Only from inside a running job. Places the run right after the current job, before +everything already waiting, whatever priority the waiting jobs carry; every page of the run +then goes before the rest. Inside a chunk page the run goes after that page. Cannot be +combined with `priority()`, and throws outside a running job. See +[Queueable nextInChain](/api/queueable#nextinchain). + +**Signature** + +```apex +ChunkBuilder nextInChain(); +``` + +**Example** + +```apex +public override void work() { + List lineIds = createLines(); + Async.chunk(new RepriceLinesJob(), ChunkSource.ofIds(lineIds)) + .chunkSize(200) + .nextInChain() + .enqueue(); +} +``` + ### Execute #### chain diff --git a/website/api/queueable.md b/website/api/queueable.md index fe5d105..bd18457 100644 --- a/website/api/queueable.md +++ b/website/api/queueable.md @@ -6,7 +6,7 @@ Apex classes `QueueableBuilder.cls`, `QueueableManager.cls`, and New to async jobs? See [Standard Apex vs Async Lib](/introduction/standard-apex-vs-async-lib#queueable) for how this maps to a plain `Queueable`. For testing patterns and best -practices, see [Testing Async Jobs](/explanations/testing-async-jobs). +practices, see [Testing Async Jobs](/features/testing-async-jobs). **Common QueueableJob class example:** @@ -145,8 +145,8 @@ The following are methods for using Async with Queueable jobs: [**Chain control**](#chain-control) -- [`stopChain()`](#stopchain) -- [`skipJob(String customJobId)`](#skipjob) +- [`Async.stopChain()`](#stopchain) +- [`Async.skipJob(String customJobId)`](#skipjob) [**Override hooks**](#override-hooks) — methods you override on your `QueueableJob` subclass (not fluent builder calls) @@ -236,6 +236,11 @@ Async.queueable(new MyQueueableJob()) Sets a delay in minutes before the job executes. Cannot be used with asyncOptions(). +The delay starts when the job is enqueued. For a job later in a chain, that is +when the job before it finishes, not when the chain was enqueued. The +[Job Timeline](/features/job-timeline#delayed-jobs) shows the delay, and the +clock time once it is known. + **Signature** ```apex @@ -246,7 +251,7 @@ QueueableBuilder delay(Integer delay); ```apex Async.queueable(new MyQueueableJob()) - .delay(5); // Execute in 5 minutes + .delay(5); // Execute 5 minutes after it is enqueued ``` #### priority @@ -352,7 +357,7 @@ never retried. `maxRetries` must not exceed the framework safety limit of `10`. Passing a higher value to `retry(...)` throws. Configuring one on `QueueableJobSetting__mdt` clamps to the limit and records why, because a Custom Metadata mistake must never stop an org's jobs from running. See -[Configuration Safety](/explanations/configuration-safety). +[Configuration Safety](/features/configuration-safety). On each failed attempt the framework re-enqueues a fresh clone of the job with an incremented attempt counter. Jobs the failed attempt chained, and any @@ -558,6 +563,74 @@ Async.queueable(new AlertOpsJob()) .enqueue(); ``` +#### name / chainName + +Display names for the record page timeline and `AsyncResult__c`. `name` is this job's; +`chainName` applies to the whole chain, the last one set wins. Both travel with the +payload, so a requeued run keeps them. Without a name the class name is shown. +See [Job Timeline](/features/job-timeline). + +**Signature** + +```apex +QueueableBuilder name(String jobName); +QueueableBuilder chainName(String chainName); +``` + +#### linkRecord / linkRecords + +Links the job to the Salesforce record(s) it works on. One `AsyncResultLink__c` row per +result and record, with `Source__c` `Salesforce` and `ObjectName__c` from the Id, and a +result row for the job whatever `CreateResult__c` says. Duplicates collapse, order is +kept, a replay reads the links back from the rows. Capped by +`QueueableJobSetting__mdt.MaxRecordLinks__c` (5000 by default and at most), with a +warning in `RetryHistory__c` for anything dropped. See +[Job Timeline](/features/job-timeline). + +**Signature** + +```apex +QueueableBuilder linkRecord(SObject record); +QueueableBuilder linkRecord(Id recordId); +QueueableBuilder linkRecords(List records); +QueueableBuilder linkRecords(List recordIds); +QueueableBuilder linkRecords(Set recordIds); +``` + +**Example** + +```apex +Async.queueable(new CommissionSplitJob(order)) + .name('Commission split') + .linkRecord(order) + .linkRecords(orderLineIds) + .enqueue(); +``` + +#### linkExternalRecord / linkExternalRecords + +Links the job to a record in another system by its key. `source` and `objectName` are +stored as given, blank stays blank; only `key` is required (`IllegalArgumentException` +otherwise). The same key under two sources is two links. Counts against the same cap as +`linkRecords`. + +**Signature** + +```apex +QueueableBuilder linkExternalRecord(String source, String objectName, String key); +QueueableBuilder linkExternalRecords(List links); +``` + +**Example** + +```apex +Async.queueable(new SyncCustomerJob(account)) + .name('Sync customer') + .linkRecord(account) + .linkExternalRecord('NetSuite', 'Customer', account.NetSuiteId__c) + .enqueue(); +``` + #### deepClone Clones provided QueueableJob by value for all the member variables. By default @@ -591,7 +664,7 @@ Attaches arbitrary key/value metadata to the job. It arrives on every lifecycle context as `ctx.info`, and survives retries, chunk pages and serialization. Use it to route alerts by team, package or owner. See -[Logging](/explanations/logging). +[Logging](/features/logging). **Signature** @@ -722,6 +795,29 @@ Async.queueable(new MyQueueableJob()) .enqueue(); ``` +#### nextInChain + +Only from inside a running job. Places this job right after the current one, before +everything already waiting, whatever priority the waiting jobs carry. Finalizers of the +current job still run first. Two calls from one job run in call order; a job inserted by +an inserted job runs before its siblings; inside a chunk page the job runs after that +page. Cannot be combined with `priority()`, and throws outside a running job. + +**Signature** + +```apex +QueueableBuilder nextInChain(); +``` + +**Example** + +```apex +public override void work() { + Invoice__c invoice = createInvoice(); + Async.queueable(new CalculateTaxJob(invoice)).nextInChain().enqueue(); +} +``` + #### asSchedulable Converts the queueable builder to a schedulable builder for cron-based @@ -928,7 +1024,7 @@ public class GuardFinalizer extends QueueableJob.Finalizer { } ``` -#### stopChain +#### Async.stopChain {#stopchain} Skips every remaining (unprocessed) job in the current chain. Nothing else runs. @@ -949,7 +1045,7 @@ void stopChain(); Async.stopChain(); ``` -#### skipJob +#### Async.skipJob {#skipjob} Skips the job with the given `customJobId` and any finalizers attached to it. Jobs that [`dependsOn`](#dependson) the skipped job are skipped in turn. Throws @@ -971,23 +1067,78 @@ void skipJob(String customJobId); Async.skipJob(notificationsResult.customJobId); ``` +#### Async.linkRecord / Async.linkRecords {#linkrecord--linkrecords-1} + +Only from inside `work()`. Links the running job to Salesforce records it discovered, the +way `linkRecord(...)` does on the builder, without any DML: the links are written with the +result row when the job settles, so a callout after the call is fine. Throws outside a +running job. + +**Signature** + +```apex +void linkRecord(SObject record); +void linkRecord(Id recordId); +void linkRecords(List records); +void linkRecords(List recordIds); +void linkRecords(Set recordIds); +``` + +**Example** + +```apex +public override void work() { + Account parent = [SELECT Id FROM Account WHERE Id = :order.AccountId]; + Async.linkRecord(parent); +} +``` + +#### linkExternalRecord / linkExternalRecords + +Only from inside `work()`. Links the running job to a record in another system by its +key, the way `linkExternalRecord(...)` does on the builder: `source` and `objectName` are +stored as given, blank stays blank, only `key` is required. No DML until the job settles. +Throws outside a running job. + +**Signature** + +```apex +void linkExternalRecord(String source, String objectName, String key); +void linkExternalRecords(List links); +``` + +**Example** + +```apex +public override void work() { + String customerId = netSuite.upsertCustomer(account); + Async.linkExternalRecord('NetSuite', 'Customer', customerId); +} +``` + #### requeue Rebuilds jobs from the payload stored on their `AsyncResult__c` records and runs them again as one chain. Needs `QueueableJobSetting__mdt.StoreJobPayload__c = Yes` before the original run, and a registered `Async.JobSerializer` on a packaged -install. See [Requeue](/explanations/requeue). +install. See [Requeue](/features/requeue). Every result that could not be replayed comes back with a reason. Throws when the call asks for more than 2,000,000 characters of payload. +Replays run in the order the original chain had (`ChainSequence__c`), whatever order +the ids were passed in. A `dependsOn` stored on a row is rebuilt when its target is in +the same call, dropped and reported when the target already satisfied it, and otherwise +skips the dependent with a reason. See +[Job Timeline](/features/job-timeline#requeue-from-the-timeline). + ::: warning Requeue replays data, not intent The payload was written by the class as it was and is rebuilt by the class as it is now. A renamed field arrives `null`; a field that changed meaning replays the old data under the new meaning, silently. If the fix changed the job's own fields, enqueue it fresh. See -[Requeue](/explanations/requeue#what-is-stored). +[Requeue](/features/requeue#what-is-stored). ::: @@ -1002,6 +1153,7 @@ RequeueSummary requeue(Set resultIds); | ---------------- | ----- | | `List requeued` | replayed | | `Map skipReasonByResultId` | the rest, and why | +| `Map droppedDependencyByResultId` | replayed without a dependency its target had already satisfied, and which one | | `Async.Result enqueueResult` | the chain they run in, `null` when nothing was replayed | **Example** @@ -1164,7 +1316,7 @@ public class ImportJob extends QueueableJob implements Async.OnJobFailed { A listener that throws never affects the job. Adding a fifth event later is a new interface, so existing listeners keep compiling. -See [Logging](/explanations/logging) for org-wide registration, the `global` +See [Logging](/features/logging) for org-wide registration, the `global` requirement and a Nebula adapter. #### ~~resetForRetry~~ {#resetforretry} @@ -1246,7 +1398,7 @@ public virtual void onFinalFailure(Async.FailureContext failureCtx); | `retryHistory` | `String` | One line per attempt with its exception type and message. | | `asyncType` | `Async.AsyncType` | `QUEUEABLE` here. `BATCHABLE` when a logger receives a batch failure. | | `salesforceJobId` | `Id` | The `AsyncApexJob` id of the job that failed. | -| `batchPhase`, `batchScope`, `batchScopeTruncated` | `String`, `String`, `Boolean` | Filled for a batch failure only. See [Batch Failures](/explanations/batch-failures#logging). | +| `batchPhase`, `batchScope`, `batchScopeTruncated` | `String`, `String`, `Boolean` | Filled for a batch failure only. See [Batch Failures](/features/batch-failures#logging). | **`Async.RetryOutcome`** diff --git a/website/explanations/batch-or-chunk.md b/website/explanations/batch-or-chunk.md index 311a7cb..a578cd8 100644 --- a/website/explanations/batch-or-chunk.md +++ b/website/explanations/batch-or-chunk.md @@ -86,7 +86,7 @@ Async.batchable(new AccountCleanupBatch()).scopeSize(200).execute(); - **Run the failed records again**, through the batch's own `execute()`, with `Async.requeueBatchFailures(...)`. -All three are on [Batch Failures](/explanations/batch-failures). +All three are on [Batch Failures](/features/batch-failures). What it will not do: retry a batch scope on its own, call a hook when a batch finishes, or put a batch inside a Queueable chain. If you need those, that is the sign the work wants to be a diff --git a/website/explanations/failures-and-the-chain.md b/website/explanations/failures-and-the-chain.md index 7318ba8..c95b536 100644 --- a/website/explanations/failures-and-the-chain.md +++ b/website/explanations/failures-and-the-chain.md @@ -176,7 +176,7 @@ of routine bookkeeping, not out of being told the framework broke. It is the only status that ignores that setting. `FRAMEWORK_ERROR` rows are cleaned up on the `othersOlderThanDays(...)` track, -see [AsyncResult Cleanup](/explanations/asyncresult-cleanup). +see [AsyncResult Cleanup](/features/asyncresult-cleanup). ::: diff --git a/website/explanations/job-state-between-runs.md b/website/explanations/job-state-between-runs.md index d6dddb3..daabdf5 100644 --- a/website/explanations/job-state-between-runs.md +++ b/website/explanations/job-state-between-runs.md @@ -180,4 +180,4 @@ org-wide outage. Instead, **retry is simply not applied** to a job that has not declared how its state resets, the job runs once as its code was written and tested to, and the reason is recorded on the job. See -[Configuration Safety](/explanations/configuration-safety). +[Configuration Safety](/features/configuration-safety). diff --git a/website/explanations/asyncresult-cleanup.md b/website/features/asyncresult-cleanup.md similarity index 66% rename from website/explanations/asyncresult-cleanup.md rename to website/features/asyncresult-cleanup.md index 9d2752d..86f9693 100644 --- a/website/explanations/asyncresult-cleanup.md +++ b/website/features/asyncresult-cleanup.md @@ -33,7 +33,11 @@ their own retention, independent from everything else. | Track | Matches `Status__c` | Builder method | | -------- | ------------------------------------------------------------------------------------------ | ---------------------------------- | | Failed | `FAILED` | `failedOlderThanDays(Integer days)` | -| The rest | `COMPLETED`, `SKIPPED_DEPENDENCY`, `SKIPPED_CHAIN_STOPPED`, `SKIPPED_CHUNK_STOPPED`, `SKIPPED_EXPLICIT`, `SKIPPED_DISABLED`, `FRAMEWORK_ERROR` | `othersOlderThanDays(Integer days)` | +| The rest | `ENQUEUED`, `COMPLETED`, `SKIPPED_DEPENDENCY`, `SKIPPED_CHAIN_STOPPED`, `SKIPPED_CHUNK_STOPPED`, `SKIPPED_EXPLICIT`, `SKIPPED_DISABLED`, `FRAMEWORK_ERROR` | `othersOlderThanDays(Integer days)` | + +An `ENQUEUED` row that never settled belongs to a job that was aborted with `System.abortJob` or +lost to a platform failure; the rest track is what clears it. `AsyncResultLink__c` rows are +master-detail children, so they go with their result; nothing else deletes them. Set one track, the other, or both. A track without a configured retention is **never touched**: @@ -59,6 +63,28 @@ new AsyncResultCleanupBatch().failedOlderThanDays(90).othersOlderThanDays(30); delayed chains are never deleted while their chain is still being reconciled. +## On a Packaged Install + +The batch is part of the package and `global`, so the same code runs from your org with the +namespace prefix on every Async Lib type: + +```apex +btcdev.Async.batchable( + new btcdev.AsyncResultCleanupBatch() + .failedOlderThanDays(90) + .othersOlderThanDays(30) + ) + .asSchedulable() + .name('AsyncResult Cleanup') + .cronExpression(new btcdev.CronBuilder().everyDay(3, 0)) + .schedule(); +``` + +The scheduled job shows under Setup, Scheduled Jobs, with the name you gave it. The rows it +deletes are `btcdev__AsyncResult__c` and, through the master-detail, `btcdev__AsyncResultLink__c`. +Nothing else is needed: no permission set, no serializer, the batch runs in system mode. See +[Installing as a Package](/introduction/packaged-install) for the prefix rules. + ## Running It Once, Ad Hoc The same batch works without scheduling, for a one-off purge: diff --git a/website/explanations/batch-failures.md b/website/features/batch-failures.md similarity index 96% rename from website/explanations/batch-failures.md rename to website/features/batch-failures.md index 73b1604..1dc4e1f 100644 --- a/website/explanations/batch-failures.md +++ b/website/features/batch-failures.md @@ -66,6 +66,10 @@ the only place a batch can report one. Successful scopes write nothing. The platform only publishes failures. +One delivery of failures handles at most 50, then the platform sends the rest in a new transaction. +A delivery can hold 2,000, and `System.LimitException` cannot be caught, so a logger spending a DML +per failure would otherwise roll back every record in it. That leaves a logger about 3 DML per call. + ::: info The job id is the one you already have For a failed `execute`, the platform event names an internal worker job, not the batch job. Async Lib resolves it, so `SalesforceJobId__c` always matches the id you got when you started the batch. @@ -210,7 +214,7 @@ starts, so it would be missed. If you put it behind a button, gate the button. It runs a batch over records the caller may not be able to see, the same concern as -[`Async.requeue`](/explanations/requeue#if-you-put-this-behind-a-button-gate-the-button). +[`Async.requeue`](/features/requeue#if-you-put-this-behind-a-button-gate-the-button). `Async.requeue(resultId)` does not accept a batch failure row. It replays a stored Queueable payload, and a batch failure has none. The skip reason points here. diff --git a/website/explanations/configuration-safety.md b/website/features/configuration-safety.md similarity index 87% rename from website/explanations/configuration-safety.md rename to website/features/configuration-safety.md index d21e9d4..bd0a72e 100644 --- a/website/explanations/configuration-safety.md +++ b/website/features/configuration-safety.md @@ -36,6 +36,9 @@ Nothing is thrown and the job runs. | `MaxRetries__c` above the framework cap | clamped to the cap | | `LoggerClass__c` cannot be resolved | no logger, jobs run normally | | `JobSerializerClass__c` cannot be resolved, or is not an `Async.JobSerializer` | no payload stored, the result records `NotSerializable`, job runs normally | +| `MaxRecordLinks__c` above the framework cap | clamped to 5000 | +| more records linked than `MaxRecordLinks__c` allows | the first ones are stored, the rest dropped, the warning says how many | +| `TrackJobProgress__c = Yes` but the `ENQUEUED` row cannot be written | the job runs; its row is written when it settles | Every fallback degrades toward doing **less**, never toward doing something the developer did not ask for. Skipping retry is safe, because the job then runs exactly once, which is what its code @@ -54,6 +57,8 @@ Anything a developer wrote, because it cannot escape their own test run: - `retry(-1)`, or a retry count above the cap passed in Apex - `delay()` combined with `asyncOptions()` - `dependsOn(Async.afterPrevious())` with no previous job +- `nextInChain()` outside a running job, or together with `priority()` +- `Async.linkRecord(...)` outside `work()` - `Async.requeue(...)` asked for more payload than it can hold in one call These throw at `.enqueue()` or `.chain()`, synchronously, in the caller's transaction. diff --git a/website/features/job-timeline.md b/website/features/job-timeline.md new file mode 100644 index 0000000..1153d0c --- /dev/null +++ b/website/features/job-timeline.md @@ -0,0 +1,333 @@ +--- +outline: deep +--- + +# Job Timeline + +## TL;DR + +Tag a job with the record it works on, give it a name, and the record page shows every job that +ran, runs or will run for that record. Failed steps can be requeued from there, in the original +order, with their dependencies rebuilt. + +```apex +Async.queueable(new ReserveStockJob(lines)) + .name('Reserve stock') + .chainName('Order fulfilment pipeline') + .linkRecord(order) + .linkRecords(lines) + .chain(new CreateInvoiceJob(order)).name('Create invoice').linkRecord(order) + .chain(new CommissionSplitJob(order)).name('Commission split').linkRecord(order).retry(2) + .chain(new AccountRollupJob(order.AccountId)) + .name('Recalculate account rollups') + .linkRecord(order) + .dependsOn(Async.afterPrevious().succeeded()) + .enqueue(); +``` + +Place the `Async Jobs` component on the Order record page in App Builder and assign +`AsyncResultAccess` to whoever should see it. That is the whole setup. + +![The Async Jobs card on a record page, with a chain running and the In flight chip lit](/images/job-timeline/running.png) + +## Linking a job to a record + +`linkRecord(...)` and `linkRecords(...)` on the builder accept what you already have in hand: an +`SObject`, an `Id`, a `List`, a `List` or a `Set`. Duplicates collapse, order is +kept, and a requeued job reads its links back from the stored rows. On a chunk run the builder +links go on the first page; a page links its own records from inside `work()`. + +A job can also point at a record that lives somewhere else: an ERP customer, a payment, a ticket. +`linkExternalRecord('NetSuite', 'Customer', '4711')` stores the key with the source system and +object name you pass, so that system can later ask what ran for its record: + +```soql +SELECT AsyncResult__r.Name, AsyncResult__r.Status__c, AsyncResult__r.JobName__c +FROM AsyncResultLink__c +WHERE Source__c = 'NetSuite' AND Key__c = '4711' +``` + +Only the key is required. `source` and `objectName` are stored exactly as given and stay blank +when you pass nothing; the framework never guesses that a key is a Salesforce Id. `Source__c` is an +open picklist with `Salesforce` as its one packaged value, add your own systems in Setup if you +want them on reports. `linkExternalRecords(List)` takes many at once. + +A job often discovers related records while it runs. Link them from inside `work()`: + +```apex +public override void work() { + Account parent = [SELECT Id FROM Account WHERE Id = :order.AccountId]; + Async.linkRecord(parent); +} +``` + +`Async.linkRecord(...)`, `linkRecords(...)` and `linkExternalRecord(...)` only buffer links on the +running job. Nothing is written until the job settles, so a job that calls out afterwards is not +affected. Outside a running job the call throws; before the job runs, use the builder. + +**Linking forces a result row.** A job linked to a record always writes its `AsyncResult__c` row, +whatever `CreateResult__c` says. You linked it so it shows up on the record; a row is how it shows +up. The links themselves are rows of `AsyncResultLink__c`, one per result and record: `Key__c` +(the Id or the external key, indexed so a record page or an integration can select by it), +`Source__c` (`Salesforce` for `linkRecord(s)`, as given for `linkExternalRecord(s)`) and +`ObjectName__c` (the API name for a Salesforce Id, as given otherwise). A record linked twice, +once from the builder and again from `work()`, is still one row: `UniqueKey__c` hashes source and +key per result, so the database rejects the second and the job carries nothing extra through the +chain. Each row counts 2 KB of data storage, like any record, and goes with its result when the +cleanup batch deletes it. + +`MaxRecordLinks__c` on `QueueableJobSetting__mdt` caps the links stored per job. Blank means 5000, +which is also the ceiling. Links beyond the limit are dropped with a warning in `RetryHistory__c`, +and the warning names the limit, the job and how many it was given. + +## Names + +`name('Reserve stock')` is the display name of one job, stored in `JobName__c`. Without it the +class name is shown. `chainName('Order fulfilment pipeline')` names the whole chain: set it on any +job, the last one set wins, and every result of the chain carries it in `ChainName__c`. Both names +travel with the payload, so a requeued run keeps them. + +## Lifecycle: seeing a chain in flight + +By default a result row is written when a job settles. Turn `TrackJobProgress__c` to `Yes` on +`QueueableJobSetting__mdt` and the row is written when the job is **enqueued**, with +`Status__c = ENQUEUED`, and updated in place when it settles. A chain of seven jobs then shows seven +rows the moment `enqueue()` returns: the first with its `SalesforceJobId__c`, the rest waiting. + +| Column | Set when | +| --- | --- | +| `ENQUEUED` | the job is handed to the chain; `SalesforceJobId__c` fills in when the platform takes it | +| `StartedAt__c` | `work()` started on the final attempt | +| `FinishedAt__c` | the job settled, so `FinishedAt__c - StartedAt__c` is the duration | +| `NextAttemptAt__c` | when the next run is due: a retry, or a delayed job already enqueued; cleared when it settles | +| `RetryAttempts__c` | updated after each failed attempt, so a row reads "attempt 2 of 3" while retrying | + +`Yes` implies a row for every outcome of that job, the same cost as `CreateResult__c`. Blank falls +back to `All`; `No` on a job record overrides `Yes` on `All`, so a noisy job can opt out. + +Nothing is written inside `work()`. The `ENQUEUED` rows are inserted right before +`System.enqueueJob`, which already blocks a later callout in the same transaction, and the terminal +update runs in the finalizer. A job that calls out is not affected either way. + +"Running" is not a stored status. The record page reads `AsyncApexJob.Status` for every `ENQUEUED` +row that has a job id and shows Queued, Running or Aborted from that. A job aborted with +`System.abortJob` keeps its `ENQUEUED` row until `AsyncResultCleanupBatch` removes it. A job +whose platform status says `Completed` or `Failed` while its row is still `ENQUEUED` shows as +Finishing: the finalizer that writes the row runs in its own transaction, seconds later. + +A chain's badge follows its lifecycle, not the mix of its steps: `QUEUED` until the first step +starts, `RUNNING` from then until every step has settled and every page of a chunk run is there, +then `COMPLETED`, `FAILED` or `STOPPED`. The mark in the badge tells what the chain is doing now, +a pulsing dot while a step executes, a hollow dot while the platform holds the next one, a refresh +arrow while a retry waits, and a red count next to the badge is the number of steps failed so far. +A chunk run that still misses pages but has been quiet for ten minutes counts as finished. + +## The record page component + +`Async Jobs` targets any record page, in Lightning and in Aura-based Experience sites. One card per +chain, newest first by the time the chain was created. A chain never moves when it finishes, so the +row you are watching stays where it is, and only a new run shifts the list. The `In flight` chip turns +blue with a pulsing dot while something runs: click it to see only those chains. A chain that finishes +while the filter is on stays visible until you leave the filter, so the chip count can be lower than +the rows shown. The card opens a chain by itself only on the first page of `All`, only when nothing +is open, and only the first chain that is running; it never does so under a filter or on a later +page. What you open or close stays that way across filters and pages, and an open chain stays open +after it settles. A chain that starts while another is open only gets a pulsing New badge and a +note in the live line, unless `Follow new runs` is on (an App Builder attribute and a switch next +to the live line): then the newcomer opens and the others collapse, for a card left open on a +screen. Every other chain collapses to two lines, the name and +badge, then a line with the relative start time and duration and one dot per step, a chunk run as a +small strip worth three dots. When those do not fit in 16 dots, or the chain is only chunk runs, a +progress bar replaces them, so no step is ever left out of the picture. Click the header row or the +chevron to toggle; the header you clicked stays where it is, and collapsing also folds any run of +completed steps you had opened. Each step shows its glyph, name, status, up to two facts +(priority, finalizer of, added by, chunk pages, dependency, requeued), timing and linked records as +chips that open the record in a new tab; the record whose page shows the card gets no chip, every +step here is linked to it. A blue outline marks the step the platform holds right +now: the one running, or, before anything runs, the one waiting in its queue or for its retry. +Click anywhere on a step for its details: +class, custom and Salesforce job ids, position, attempts, timings, dependencies, the exception, the +retry history and a link to the result record. A requeued step links to its replay: in place when +the replay is loaded, otherwise to its result record. + +![Collapsed chains: one dot per step, a small strip for a chunk run, a progress bar when the steps do not fit](/images/job-timeline/list.png) + +A step whose Salesforce job finished while its row is still `ENQUEUED` shows as **Finishing**: the +finalizer that writes the result runs in its own transaction, often seconds later. + +The toolbar has a range menu (24 hours, 7 days, 30 days, all time), refresh, the legend (a panel +over the list, so the card keeps its height; Escape or a click outside closes it) and `View +all results`, which opens the `By chain` list view. The chips count every chain in the range, not +just the page, and filter the list. Chains come in blocks of 50 so the footer pager answers in +memory. The list has a fixed height and pages +hold as many collapsed chains as fit in it, so a page never scrolls until you open a chain. The +refresh button reloads in place. + +App Builder attributes: + +| Attribute | Default | Effect | +| --- | --- | --- | +| Card title | Async Jobs | | +| Default range in days | 7 | the range selected when the card opens; blank means 7 | +| Sequences per page | 0 | chains per page; 0 fits as many collapsed chains as the height allows | +| Max height in pixels | 600 | the fixed height of the list; 0 lets the card grow | +| Turn off live refresh | off | by default the card polls while a chain is in flight | +| Follow new runs | off | a chain that enters in flight opens by itself and the others collapse | + +Live refresh is polling, not Platform Events: every 3 seconds while a job runs or finishes, every +10 while the chain is queued, and when everything in flight is a scheduled retry or a delayed job, +not before the earliest is due (10 seconds to 5 minutes). The live line says `Live` or `Idle`, +with the cadence in its tooltip, and spells out only what would otherwise look like silence: the +clock time of the next check during a retry wait, a paused tab, a stop after failed checks, a +stale card. It pauses while the tab is hidden and +checks the moment the tab is back, keeps going through a failed check and stops after three in a +row or after 30 minutes. After a requeue it keeps watching for two minutes even when nothing looks +in flight, because a replay without progress tracking has no rows until it settles. An idle card +that has not checked for ten minutes turns the refresh button blue and says so in the live line. +A Platform Event subscription per open tab would spend the org's delivery allocation, which is +shared with Change Data Capture and every integration, and a subscription cannot be filtered to one +record. A new step that appears while the card is open slides in with a New badge; a step whose +status changed flashes once. + +An open chain sits on a light panel with a line through its steps, so the next chain is easy to tell +apart. The steps follow the order the chain runs them, and a finalizer branches off its parent. A job +added while you watch, say one with a high priority, slides in at the place it will run with a New +badge. Open the card later and the same job just sits in that place, with its `added by` tag. A tag that +names another job (`after Commission split succeeded`, `added by`, `finalizer of`) highlights that +job's row on hover or keyboard focus. A row shows two tags and keeps the rest behind a +N with their +text on hover. + +While a chunk run is alive the chain header counts its pages. Once it has ended in a chain with other +steps, the run counts as one step and the line under the name gives `6 steps · 16 pages`. A chunk row +with failed pages lists each page with its error and a link to its result, pages sharing one error +grouped together, and its other link opens the first page. + +![A failed chunk run: the failing page with its own error and a link to its result](/images/job-timeline/failed-chunk.png) + +The card adapts to its own width, not the screen's, so it works in a narrow sidebar column. Below +640 px the buttons stay on the first row of each chain: requeue shrinks to an icon with the count, +the range to `7 days` and `View all results` to an icon. Very narrow cards hide the footer's brand +text before the pager. + +The same card in a narrow record page sidebar + +The card holds up to 50 chains in the browser, so pages inside that block change without a server +call. With up to 50 chains on the record a filter applies at once to what is loaded, then a quiet +refresh updates the view the way a live update does. If that refresh fails, the list stays and the +live line says so. Beyond 50 chains the browser cannot filter alone: the rows stay until the answer +arrives, and a thin blue line under the filters shows when the wait passes 300 ms. Background +refreshes never show it. + +What the card refuses to render without a way out: + +| Cap | Beyond it | +| --- | --- | +| 20 steps on an expanded card | the first 3, the last 2 and every step that is not completed stay; each run of completed steps folds into one line that opens on click | +| 200 steps fully expanded | a note points to the first result record | +| 60 chunk pages as squares | a bar with page counts | +| 10 failed pages listed on a chunk row | a count of the rest | +| 3 linked records as chips, the page record excluded | a +N chip, 20 when opened; a chunk row shows the union of its pages; a record in another system is a grey chip with object and key, no link | +| 300 characters of exception | a more link | +| 40 nodes on the chain map | runs of completed nodes collapse into one that opens on click | + +The viewer needs read on `AsyncResult__c` and `AsyncResultLink__c` plus access to the controller +class. The `AsyncResultAccess` permission set grants all of it. Without it the card says so by name. + +## Requeue from the timeline + +![A failed chain opened: the failed step with its error and retry history, dependent steps skipped, and the requeue buttons](/images/job-timeline/failed-chain.png) + +The requeue actions show only for a viewer holding the `AsyncRequeue` custom permission, shipped in +the `AsyncRequeueAccess` permission set. `Async.requeue` in Apex stays ungated like every other +`Async` call. + +`Requeue failed steps` appears once the chain has settled. It asks for confirmation, then collects +every `FAILED` and skipped row of the chain that has a stored payload and replays them as one new +chain, in the original order. A step whose payload was +not stored is listed in the outcome, never silently skipped. The new chain appears at the top of the +card with a `Replay of` badge, and the source rows switch to `Requeued`. + +Dependencies travel with the row in `Dependencies__c` and are rebuilt on replay: + +| The dependency points at | Then | +| --- | --- | +| a row in the same requeue call | the replayed dependent depends on the replayed target | +| a row outside the call whose outcome satisfied it | the dependency is dropped and reported in `droppedDependencyByResultId` | +| a row outside the call whose outcome did not satisfy it | the dependent is skipped with a reason; requeue both, or enqueue it fresh | +| a row that no longer exists | the dependent is skipped with a reason | + +## The chain map + +The ▾ menu on a card opens the chain as a pipeline: one node per step in the order the framework +will run them, with the name in full, position, duration or state, priority and who inserted it. +Dependencies are drawn as elbow connectors above the nodes, green when met, red and dashed when +not, grey while pending, each labelled with the rule. Finalizers hang under their parent, a chunk +run shows its page strip, runtime inserts are dashed. The map opens on the first failed node, or +the one running now; click any node for its details. The map shows the order as sorted now; a job chained later at runtime +appears on the next refresh. + +![The chain map: one node per step, with dependency arcs marked met or not met](/images/job-timeline/chain-map.png) + +## Running a job next in the chain + +Inside a running job, `nextInChain()` places a new job right after the current one, before +everything already waiting, whatever priority the waiting jobs carry: + +```apex +public class CreateInvoiceJob extends QueueableJob { + public override void work() { + Invoice__c invoice = createInvoice(); + if (invoice.NeedsTaxCalculation__c) { + Async.queueable(new CalculateTaxJob(invoice)) + .name('Calculate tax') + .linkRecord(invoice) + .nextInChain() + .enqueue(); + } + } +} +``` + +Finalizers of the current job still run first. Two `nextInChain()` calls from one job run in call +order, and a job inserted by an inserted job runs before its siblings. Inside a chunk page the +inserted job runs after that page and before the next one. `nextInChain()` cannot be combined with +`priority()`, and outside a running job it throws: there, `chain()` and `enqueue()` already run jobs +in order. The row records who added it in `AddedByCustomJobId__c`, the same as for a job a running +job adds with `chain()` or `enqueue()`. + +## Delayed jobs + +A job with `delay(n)` is enqueued when its turn comes, so its delay starts when the job before it finishes. The card only shows a clock time it can actually know: + +| State | Chain badge | Chain line | Job row | +| --- | --- | --- | --- | +| Nothing has started, the first job is delayed | `DELAYED` | `starts at 10:05` | `Delayed`, `until 10:05` | +| Running, a later job has a delay | `RUNNING` | `running 2 min` | `Enqueued`, `delay 10 min` | +| Running, the next job is enqueued and waiting | `RUNNING` \| `DELAYED` | `next step at 10:18` | `Delayed`, `until 10:18` | +| Same, and a job has already failed | `RUNNING` \| `✕ 1` \| `DELAYED` | `next step at 10:18` | `Delayed`, `until 10:18` | + +The time is the earliest the job can start. If it passes and the job has not started, the org is busy: the row goes back to `Queued` and the chain drops `DELAYED`. `delayBetweenChunks` works the same way between pages. A retry keeps its own `Retrying` state. A job scheduled with `Async.schedulable` is not delayed: it has no row until the schedule fires. + +## Fields added to `AsyncResult__c` + +| Field | Holds | +| --- | --- | +| `JobName__c`, `ChainName__c` | the names | +| `ChainSequence__c` | position inside the chain, in the order it runs, and the requeue order. It can change only while the job waits | +| `Priority__c` | the priority, when set | +| `ParentCustomJobId__c` | for a finalizer, the job it was attached to | +| `AddedByCustomJobId__c` | for a job added while another job ran, the job that added it | +| `ChunkRunId__c`, `ChunkPage__c`, `ChunkPages__c` | for a chunk page, its run and position | +| `DelayMinutes__c` | the delay set on the job; a backoff between retries does not count | +| `Dependencies__c` | the `dependsOn` rules as JSON | +| `StartedAt__c`, `FinishedAt__c`, `NextAttemptAt__c` | timing | + +Every field is on the page layout, the record page and in `AsyncResultAccess`. The object has a tab +and four list views: Failed (last 7 days), In flight, Replayable and By chain. + +## On a packaged install + +The component, the permission sets and the custom permission ship in the package. Place +`Async Jobs` from App Builder, assign `btcdev__AsyncResultAccess` to viewers and +`btcdev__AsyncRequeueAccess` to operators. Requeue from the timeline needs the same +`AsyncJobSerializer` registration as `Async.requeue()`, see [Requeue](/features/requeue). diff --git a/website/explanations/logging.md b/website/features/logging.md similarity index 93% rename from website/explanations/logging.md rename to website/features/logging.md index 2dbcf2c..80dd482 100644 --- a/website/explanations/logging.md +++ b/website/features/logging.md @@ -48,6 +48,10 @@ failures implements one method and nothing else. | `Async.OnJobFailed` | a job failed with no attempts left | `FailureContext` | | `Async.OnRetryEnqueued` | an attempt failed and another is queued | `FailureContext` | +Both contexts carry `jobName` and `chainName` from `name(...)` and `chainName(...)` on the builder, +next to `info`. Linked records are not on the context; they are rows of `AsyncResultLink__c`, +reachable through `AsyncResult__r.CustomJobId__c` with the `customJobId` the context has. + A job that fails with `retry(2)` and never succeeds produces `OnRetryEnqueued`, `OnRetryEnqueued`, `OnJobFailed`. Every failed attempt fires exactly one event, so the two together tell you whether to warn or to page. There is no overlap and no double counting. @@ -57,7 +61,7 @@ Chunk runs fire `OnJobEnqueued` per page, because each page really is queued sep A batch that implements `Database.RaisesPlatformEvents` fires `OnJobFailed` for every failed scope, with `ctx.asyncType == Async.AsyncType.BATCHABLE` and the `batchPhase` and `batchScope` fields filled. It fires nothing else, and the logger runs as the Automated Process user for those. See -[Batch Failures](/explanations/batch-failures#logging). +[Batch Failures](/features/batch-failures#logging). ## Two layers, one vocabulary @@ -114,7 +118,7 @@ job carries on untouched. By the time most events fire the job has already done failing it over a logging problem would turn an observability problem into a data problem. The same applies to a `LoggerClass__c` that cannot be resolved: jobs keep running, and the reason -is recorded. See [Configuration Safety](/explanations/configuration-safety). +is recorded. See [Configuration Safety](/features/configuration-safety). ## Testing your logger diff --git a/website/explanations/requeue.md b/website/features/requeue.md similarity index 77% rename from website/explanations/requeue.md rename to website/features/requeue.md index 080858b..a579495 100644 --- a/website/explanations/requeue.md +++ b/website/features/requeue.md @@ -51,12 +51,13 @@ The job as you handed it to `enqueue()`, before it ran. Not the failed attempt. has already mutated its own state, and replaying from that is the bug [the state gate](/explanations/job-state-between-runs) exists to prevent. -| Travels with the payload | Does not | -| ------------------------ | -------- | -| your own fields, `retry(n)`, `info(...)` | `backoff(...)`, `dependsOn(...)`, chain position, retry defaults from Custom Metadata | +| Travels with the payload | Stored next to it | Does not | +| ------------------------ | ----------------- | -------- | +| your own fields, `retry(n)`, `info(...)`, `name(...)`, `chainName(...)` | `dependsOn(...)` in `Dependencies__c`, chain position in `ChainSequence__c`, every link in `AsyncResultLink__c`, the builder's and the ones made inside `work()` | `backoff(...)`, retry defaults from Custom Metadata | -So a replay retries the way the original did, but it starts a fresh chain, takes the retry -defaults from today's Custom Metadata, and does not carry dependencies from the old chain. +So a replay retries the way the original did, starts a fresh chain, takes the retry defaults from +today's Custom Metadata, and rebuilds its dependencies against the other rows of the same call +(below). Chunk pages and finalizers are never stored. A page needs its source and a finalizer needs its parent job, and neither of those survives on a record. They get `NotSerializable` on the row so you @@ -129,6 +130,8 @@ Async Lib converts the job itself. The full checklist for a packaged install is | `PayloadSize__c` | its length in characters | | `RequeueStatus__c` | whether it can be replayed | | `RequeuedFrom__c` | the result this one was replayed from | +| `ChainSequence__c` | the order replays run in | +| `Dependencies__c` | the `dependsOn` rules, rebuilt on replay | `JobPayload__c` is a Long Text Area, and Long Text cannot be filtered on. `WHERE JobPayload__c != null` does not even compile. That is why `RequeueStatus__c` exists, and it is what you select by. @@ -157,9 +160,23 @@ Set failed = new Map([ Async.requeue(failed); ``` -All the replays go into **one chain** and run in sequence. That matters when requeue itself runs -from a Queueable, where only one job can be enqueued per transaction. One chain costs one slot, and -a replay that fails does not stop the ones after it. +All the replays go into **one chain** and run in sequence, in the order the original chain had, +whatever order the ids were passed in. That matters when requeue itself runs from a Queueable, +where only one job can be enqueued per transaction. One chain costs one slot, and a replay that +fails does not stop the ones after it. + +A `dependsOn` stored on a row is handled by where its target is: + +| The target is | Then | +| --- | --- | +| in the same call | the replayed dependent depends on the replayed target, same outcome rule | +| outside the call, and its outcome satisfied the rule | the dependency is dropped; `droppedDependencyByResultId` says which | +| outside the call, and its outcome did not | the dependent is skipped with a reason in `skipReasonByResultId`; requeue both, or enqueue it fresh | +| gone, deleted by the cleanup batch | the dependent is skipped with a reason | + +So "requeue the failed step and everything it blocked" is one call with both ids, and "requeue +only the dependent after its blocker was fixed by hand" also works, because the blocker's +`COMPLETED` row satisfies the rule. There is a limit of 2,000,000 characters of payload per call, checked before any payload is loaded. Over that, `Async.requeue` throws instead of dying halfway through. If you have more than that to @@ -186,13 +203,16 @@ rather than another replay. that is fine: whoever can write the call could do anything else too. Once you expose it to end users, through an `@AuraEnabled` method, a Flow action or a screen, the user gets to pick which stored job runs, in system context, with the data it carried. Check a custom permission in your -controller before you call it. +controller before you call it. The record page component that ships with Async Lib does exactly +that with the `AsyncRequeue` custom permission, see +[Job Timeline](/features/job-timeline#requeue-from-the-timeline). ## Limits -- Requeue replays one job, not its chain. The rest of the chain is not rebuilt. +- A replay is a new chain of exactly the rows you pass. Rows you leave out are not rebuilt; their + outcomes only decide whether a dependency on them is dropped or blocks the dependent. - A batch failure has no payload. Run its failed records again with - [`Async.requeueBatchFailures`](/explanations/batch-failures#running-the-failed-records-again). + [`Async.requeueBatchFailures`](/features/batch-failures#running-the-failed-records-again). - `AsyncResultCleanupBatch` deletes old records, so a replay is bounded by your retention window. - `AsyncResultAccess` grants read on `JobPayload__c`. Review who holds that permission set before you turn storage on. diff --git a/website/explanations/testing-async-jobs.md b/website/features/testing-async-jobs.md similarity index 100% rename from website/explanations/testing-async-jobs.md rename to website/features/testing-async-jobs.md diff --git a/website/getting-started.md b/website/getting-started.md index 493b9e2..bbcb8f7 100644 --- a/website/getting-started.md +++ b/website/getting-started.md @@ -312,7 +312,7 @@ why. Query by **ChainId\_\_c** to see a whole chain run as a timeline. - **RetryHistory\_\_c**: Per-attempt retry log These records are never deleted automatically. See -[AsyncResult Cleanup](/explanations/asyncresult-cleanup) for the scheduled +[AsyncResult Cleanup](/features/asyncresult-cleanup) for the scheduled cleanup batch with separate retention for failed results and the rest. ## What's Next? diff --git a/website/index.md b/website/index.md index 84c2d43..5a29a41 100644 --- a/website/index.md +++ b/website/index.md @@ -4,17 +4,23 @@ layout: home hero: name: "Async Lib" - text: "Salesforce Apex Async Framework" - tagline: Eliminate queueable limits, unify async processing, and build resilient job chains with elegant error handling + text: "Async Apex you can actually monitor" + tagline: 'The Salesforce library for Queueable, Batch and Schedule behind one API. No more "Too many queueable jobs". Failures retry or get requeued, and every linked chain shows live on its record.' actions: - theme: brand text: Get Started link: /getting-started - theme: alt - text: View Examples - link: /introduction/standard-apex-vs-async-lib + text: See the Job Timeline + link: /features/job-timeline features: + - title: Job Timeline on the Record + details: Every chain linked to a record, live on its record page. Steps, chunk progress, failures, what added what, and a map of what depends on what. + link: /features/job-timeline + - title: Requeue Failed Work + details: Run failed steps again from the record page, in order, with their dependencies. Failed batch scopes go again through the batch's own execute(). + link: /features/requeue - title: Smart Queueable Jobs details: Automatically handles "Too many queueable jobs" errors through intelligent chaining and batch overflow. Features priority-based execution, sophisticated error handling, and powerful finalizers for cleanup logic. link: /api/queueable @@ -29,3 +35,60 @@ features: link: /api/schedulable --- +
+ +## See it, then fix it + +**Watch a chain run.** Jobs, chunk pages and retries update in place, and a job added at runtime shows up where it will run. + +![A running chain on the record page, with a chunk run and a live progress bar](/images/job-timeline/running.png) + +**Find out what broke and why.** The failed step opens with its exception, and the steps it took down with it are marked as skipped. Requeue them in one click. + +![A failed chain with its exception and the requeue button](/images/job-timeline/failed-chain.png) + +**See what depends on what.** The chain map draws the run order and every dependency, met or not. + +![The chain map with dependency lines between jobs](/images/job-timeline/chain-map.png) + +
+ +
+ +## Less code than doing it by hand + +Your job logic stays the same. Async Lib takes over enqueueing, chaining and tracking. + +::: code-group + +```apex [Async Lib] +public class SyncAccountsJob extends QueueableJob { + public override void work() { + // your logic + } +} + +Async.queueable(new SyncAccountsJob()) + .linkRecord(account.Id) + .chain(new NotifyOwnersJob()) + .enqueue(); +``` + +```apex [Standard Apex] +public class SyncAccountsJob implements Queueable { + public void execute(QueueableContext context) { + // your logic + System.enqueueJob(new NotifyOwnersJob()); + // one child per Queueable: a second enqueue here throws + // "Too many queueable jobs". Nothing records what ran. + } +} + +System.enqueueJob(new SyncAccountsJob()); +``` + +::: + +[Every pattern side by side](/introduction/standard-apex-vs-async-lib) or [install it](/getting-started). + +
diff --git a/website/introduction/packaged-install.md b/website/introduction/packaged-install.md index 0ef499c..4ba54e0 100644 --- a/website/introduction/packaged-install.md +++ b/website/introduction/packaged-install.md @@ -64,7 +64,7 @@ for the features you use and rename them however you like. Callouts are a marker, not a base class, so `implements Database.AllowsCallouts` works on any of them. Why the base classes exist is in [Deep Clone in Packages](/explanations/deep-clone-in-packages), and the serializer in -[Requeue](/explanations/requeue). +[Requeue](/features/requeue). ### 4. Declare `global` on anything you register by name @@ -79,16 +79,27 @@ must be `global`, or the name resolves to nothing. Only the class needs it, the A name that does not resolve degrades rather than throws: no logging, or `NotSerializable` on the result, plus a warning in `RetryHistory__c` naming the field. It never stops a job. See -[Configuration Safety](/explanations/configuration-safety). +[Configuration Safety](/features/configuration-safety). ### 5. Assign the permission set `btcdev__AsyncResultAccess` grants read on `btcdev__AsyncResult__c` and every field on it, for -admins and reports. The framework writes those records in system context and does not need it -itself. +admins and reports, and access to the `Async Jobs` component for viewers. The framework writes +those records in system context and does not need it itself. `JobPayload__c` is part of that set. If you turn `StoreJobPayload__c` on, whoever holds the set can -read whatever data your jobs carried, so review it first. +read whatever data your jobs carried, so review it first. The set also covers +`btcdev__AsyncResultLink__c`, the links between results and the records a job worked on, in +Salesforce or elsewhere. + +`btcdev__AsyncRequeueAccess` grants the `AsyncRequeue` custom permission, which shows the requeue +actions on the `Async Jobs` record page component. Assign it to operators only. + +### 6. Place the record page component + +`Async Jobs` is in App Builder under custom components, for Lightning record pages and Aura-based +Experience site record pages. Drop it on any record page; the viewer needs +`btcdev__AsyncResultAccess`. See [Job Timeline](/features/job-timeline). ## Feature by feature @@ -103,6 +114,9 @@ What each feature needs on a packaged install, and nothing more. | `LoggerClass__c` | your logger class, declared `global` | | `Async.requeue()` | `AsyncJobSerializer`, copied and registered | | batch failure records, `Async.requeueBatchFailures()`, `Async.BatchRecordLoader` | the prefix | +| `name`, `chainName`, `linkRecord(s)`, `linkExternalRecord(s)`, `Async.linkRecord(s)`, `nextInChain()` | the prefix | +| `AsyncResultCleanupBatch`, scheduled or ad hoc | the prefix on the batch, `Async` and `CronBuilder`; see [AsyncResult Cleanup](/features/asyncresult-cleanup#on-a-packaged-install) | +| the `Async Jobs` record page component | `btcdev__AsyncResultAccess` for viewers, `btcdev__AsyncRequeueAccess` plus `AsyncJobSerializer` for requeue | ## Writing tests against the package diff --git a/website/introduction/source-deploy.md b/website/introduction/source-deploy.md index 78f9805..2c8e677 100644 --- a/website/introduction/source-deploy.md +++ b/website/introduction/source-deploy.md @@ -96,7 +96,7 @@ are not in the repository and are untouched either way. framework writes those records in system context and does not need it. 2. Open `QueueableJobSetting__mdt` and decide what `All` should say. Nothing is on by default: no result rows, no retry, no logger, no payload storage. See - [Configuration Safety](/explanations/configuration-safety) for what a wrong value does. + [Configuration Safety](/features/configuration-safety) for what a wrong value does. 3. Write your first job. [Getting Started](/getting-started). ## Switching to the package later diff --git a/website/introduction/standard-apex-vs-async-lib.md b/website/introduction/standard-apex-vs-async-lib.md index 52b39e0..33dad1a 100644 --- a/website/introduction/standard-apex-vs-async-lib.md +++ b/website/introduction/standard-apex-vs-async-lib.md @@ -314,7 +314,7 @@ result tracking on top. Retry, finalizers and chaining are Queueable features, and Async Lib keeps them there on purpose. For new work over many records, a [chunk run](/api/chunk) is the recommended path. [Batch or Chunk?](/explanations/batch-or-chunk) has the -reasoning, and [Batch Failures](/explanations/batch-failures) covers what a +reasoning, and [Batch Failures](/features/batch-failures) covers what a batch does get: recorded failures, logging and requeue. **Standard Apex** diff --git a/website/public/images/job-timeline/chain-map.png b/website/public/images/job-timeline/chain-map.png new file mode 100644 index 0000000..e2635e4 Binary files /dev/null and b/website/public/images/job-timeline/chain-map.png differ diff --git a/website/public/images/job-timeline/failed-chain.png b/website/public/images/job-timeline/failed-chain.png new file mode 100644 index 0000000..c073ea7 Binary files /dev/null and b/website/public/images/job-timeline/failed-chain.png differ diff --git a/website/public/images/job-timeline/failed-chunk.png b/website/public/images/job-timeline/failed-chunk.png new file mode 100644 index 0000000..552230d Binary files /dev/null and b/website/public/images/job-timeline/failed-chunk.png differ diff --git a/website/public/images/job-timeline/list.png b/website/public/images/job-timeline/list.png new file mode 100644 index 0000000..b2681e9 Binary files /dev/null and b/website/public/images/job-timeline/list.png differ diff --git a/website/public/images/job-timeline/running.png b/website/public/images/job-timeline/running.png new file mode 100644 index 0000000..0b5a1a3 Binary files /dev/null and b/website/public/images/job-timeline/running.png differ diff --git a/website/public/images/job-timeline/sidebar.png b/website/public/images/job-timeline/sidebar.png new file mode 100644 index 0000000..cfc327e Binary files /dev/null and b/website/public/images/job-timeline/sidebar.png differ