diff --git a/.github/workflows/auto-pr.yml b/.github/workflows/auto-pr.yml
index 6c21bb6baf..d8a1d0b8d7 100644
--- a/.github/workflows/auto-pr.yml
+++ b/.github/workflows/auto-pr.yml
@@ -1,13 +1,13 @@
-name: Merge branch dev with rel-10.5
+name: Merge branch dev with rel-10.6
on:
push:
branches:
- - rel-10.5
+ - rel-10.6
permissions:
contents: read
jobs:
- merge-dev-with-rel-10-5:
+ merge-dev-with-rel-10-6:
permissions:
contents: write # for peter-evans/create-pull-request to create branch
pull-requests: write # for peter-evans/create-pull-request to create a PR
@@ -18,14 +18,14 @@ jobs:
ref: dev
- name: Reset promotion branch
run: |
- git fetch origin rel-10.5:rel-10.5
- git reset --hard rel-10.5
+ git fetch origin rel-10.6:rel-10.6
+ git reset --hard rel-10.6
- name: Create Pull Request
uses: peter-evans/create-pull-request@v3
with:
- branch: auto-merge/rel-10-5/${{github.run_number}}
- title: Merge branch dev with rel-10.5
- body: This PR generated automatically to merge dev with rel-10.5. Please review the changed files before merging to prevent any errors that may occur.
+ branch: auto-merge/rel-10-6/${{github.run_number}}
+ title: Merge branch dev with rel-10.6
+ body: This PR generated automatically to merge dev with rel-10.6. Please review the changed files before merging to prevent any errors that may occur.
draft: true
token: ${{ github.token }}
- name: Merge Pull Request
@@ -33,5 +33,5 @@ jobs:
GH_TOKEN: ${{ secrets.BOT_SECRET }}
run: |
gh pr ready
- gh pr review auto-merge/rel-10-5/${{github.run_number}} --approve
- gh pr merge auto-merge/rel-10-5/${{github.run_number}} --merge --auto --delete-branch
+ gh pr review auto-merge/rel-10-6/${{github.run_number}} --approve
+ gh pr merge auto-merge/rel-10-6/${{github.run_number}} --merge --auto --delete-branch
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4dbfba3fa9..748e2a6dd6 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -123,7 +123,7 @@
-
+
@@ -169,7 +169,7 @@
-
+
diff --git a/common.props b/common.props
index 8e25ef7520..25d59f8ecb 100644
--- a/common.props
+++ b/common.props
@@ -1,8 +1,8 @@
latest
- 10.6.0-preview
- 5.6.0-preview
+ 10.7.0-preview
+ 5.7.0-preview
$(NoWarn);CS1591;CS0436
https://abp.io/assets/abp_nupkg.png
https://abp.io/
diff --git a/docs/en/Blog-Posts/2026-07-06-ABP-Summer-Campaign/post.md b/docs/en/Blog-Posts/2026-07-06-ABP-Summer-Campaign/post.md
new file mode 100644
index 0000000000..f7203e5dce
--- /dev/null
+++ b/docs/en/Blog-Posts/2026-07-06-ABP-Summer-Campaign/post.md
@@ -0,0 +1,55 @@
+Summer is here, and so is one of the best times to start building with ABP.
+
+From **July 6 to July 20**, we're offering exclusive summer savings on **ABP licenses and renewals**. Save **20% on new licenses** or **10% on license renewals**, and receive **up to $300 in AI credits** to power the **ABP AI Agent** in **ABP Studio**.
+
+Whether you're starting a new project or upgrading your development workflow, this campaign helps you save on your license while accelerating development with AI.
+
+### **What's Included?**
+
+**During the campaign period, you'll receive:**
+
+* **20% off new ABP licenses**
+* **10% off license renewals**
+* **Up to $300 in AI credits** for the **ABP AI Agent**
+
+The AI credits can be used with the **ABP AI Agent** in **ABP Studio**, allowing you to automate repetitive development tasks and build applications faster.
+
+### **Build Faster with the ABP AI Agent**
+
+The ABP AI Agent is designed specifically for ABP developers. Rather than acting as a generic coding assistant, it understands your ABP solution and helps automate common development workflows.
+
+With the included AI credits, you can:
+
+* Generate application features with AI assistance
+* Create entities, services, and UI components faster
+* Run automated development workflows
+* Generate database migrations and update projects
+* Inspect exceptions and troubleshoot issues
+* Execute development tasks directly from ABP Studio
+
+The result is less time spent on repetitive work and more time focused on building your application's business value.
+
+### **Why Choose ABP?**
+
+ABP is a complete application development platform for building modern, maintainable, and scalable .NET applications.
+
+With ABP, you can:
+
+* Build enterprise-grade ASP.NET Core applications faster
+* Follow Domain-Driven Design (DDD) and clean architecture principles
+* Develop modular, reusable, and maintainable application modules
+* Leverage built-in capabilities such as multi-tenancy, authentication, authorization, localization, auditing, and more
+* Scale from modular monoliths to microservice architectures
+* Boost developer productivity with ABP Studio and the ABP AI Agent
+
+Instead of spending weeks building common infrastructure, your team can focus on delivering business value and shipping features faster.
+
+## **Don't Miss This Limited-Time Offer**
+
+This campaign is available **only between July 6 and July 20**.
+
+Whether you're purchasing your first ABP license or renewing your existing one, now is the perfect time to save. Get **20% off new licenses** or **10% off renewals**, plus receive **up to $300 in AI credits** to accelerate development with the **ABP AI Agent**.
+
+**Claim your summer discount before July 20 and start building faster with ABP.**
+
+**Get your discount now:** [https://abp.io/pricing](https://abp.io/pricing)
diff --git a/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/POST.md b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/POST.md
new file mode 100644
index 0000000000..ec8e9257c9
--- /dev/null
+++ b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/POST.md
@@ -0,0 +1,180 @@
+# ABP Platform 10.6 RC Has Been Released
+
+We are happy to release [ABP](https://abp.io) version **10.6 RC** (Release Candidate). This blog post introduces the new features and important changes in this new version.
+
+Try this version and provide feedback for a more stable version of ABP v10.6! Thanks to you in advance.
+
+## Get Started with the 10.6 RC
+
+You can check the [Get Started page](https://abp.io/get-started) to see how to get started with ABP. You can either download [ABP Studio](https://abp.io/get-started#abp-studio-tab) (**recommended**, if you prefer a user-friendly GUI application - desktop application) or use the [ABP CLI](https://abp.io/docs/latest/cli).
+
+By default, ABP Studio uses stable versions to create solutions. Therefore, if you want to create a solution with a preview version, first you need to create a solution and then switch your solution to the preview version from the ABP Studio UI:
+
+
+
+## Migration Guide
+
+You can check the migration guide if you are upgrading from v10.5 or earlier: [ABP Version 10.6 Migration Guide](https://abp.io/docs/10.6/release-info/migration-guides/abp-10-6).
+
+## What's New with ABP v10.6?
+
+In this section, I will introduce some major features released in this version.
+Here is a brief list of titles explained in the next sections:
+
+- Background Jobs: Dedicated Workers, Parallel Execution, and Successful Job Retention
+- API Definition and Proxy Improvements for Content Types and Multipart Uploads
+- Angular UI: Upgrade to Angular 22
+- Antiforgery and OpenIddict Security Improvements
+- OpenIddict: Generate Access Token from the UI
+- Dependency Updates
+
+### Background Jobs: Dedicated Workers, Parallel Execution, and Successful Job Retention
+
+ABP v10.6 adds three opt-in enhancements to the default background job worker. All of them are disabled by default, so existing applications keep the current behavior unless you enable them explicitly.
+
+**Storing successful jobs**
+
+By default, a job is deleted as soon as it runs successfully. You can now set `StoreSuccessfulJobs = true` to keep completed jobs in the store. A new `CompletionTime` column marks completed jobs, and a cleanup worker prunes them after `SuccessfulJobRetentionTime` (default: 7 days).
+
+**Dedicated workers per job type**
+
+`AddDedicatedWorker(...)` registers a worker that processes only the configured job argument types, each with its own distributed lock. The default worker continues handling all remaining job types.
+
+**Parallel job execution**
+
+Set `MaxParallelJobExecutionCount` greater than 1 to execute multiple jobs in the same poll cycle. In this mode, each job is claimed with its own distributed lock so different application instances can process different jobs concurrently without running the same job twice.
+
+Example configuration:
+
+```csharp
+Configure(options =>
+{
+ options.StoreSuccessfulJobs = true;
+ options.SuccessfulJobRetentionTime = TimeSpan.FromDays(30);
+
+ options.AddDedicatedWorker("NotificationWorkerLock");
+ options.AddDedicatedWorker("ReportWorkerLock");
+
+ options.MaxParallelJobExecutionCount = 4;
+});
+```
+
+These options are useful when you need better isolation between job types, higher throughput in clustered deployments, or an audit trail of successfully completed jobs.
+
+> See the [Background Jobs](https://abp.io/docs/10.6/framework/infrastructure/background-jobs) documentation and [#25742](https://github.com/abpframework/abp/pull/25742) for details.
+
+### API Definition and Proxy Improvements for Content Types and Multipart Uploads
+
+ABP v10.6 improves API definition generation and client proxies for file upload scenarios and non-JSON response types.
+
+The API definition now exposes response `ContentTypes` and an `IsRemoteStream` flag. C#, jQuery, and Angular proxies can use the declared media type instead of collapsing everything to `application/json` and `text/plain`.
+
+For upload DTOs containing `IRemoteStreamContent`, generated Angular and jQuery proxies now forward `FormData` as multipart requests instead of silently dropping the file payload or trying to serialize the stream as JSON.
+
+Server-side setup still follows the existing ABP pattern:
+
+```csharp
+Configure(options =>
+{
+ options.ConventionalControllers.FormBodyBindingIgnoredTypes.Add(typeof(UploadFileDto));
+});
+```
+
+Angular client example after proxy regeneration:
+
+```typescript
+const fd = new FormData();
+fd.append('Name', 'logo');
+fd.append('File', fileInput.files[0], 'logo.png');
+this.fileService.uploadFile(fd).subscribe(result => ...);
+```
+
+This closes long-standing gaps in generated proxies for stream-based uploads and improves support for text, blob, and custom response types.
+
+> See [#25639](https://github.com/abpframework/abp/pull/25639) for details.
+
+### Angular UI: Upgrade to Angular 22
+
+ABP v10.6 upgrades the Angular UI stack to **Angular 22.0.x**.
+
+This release also improves the locale loading mechanism with a fallback path, so culture resources load more reliably when optional locale files are missing or partially available.
+
+If you maintain a custom Angular UI on top of ABP, plan for the Angular 22 upgrade together with your ABP package update and regenerate proxies after upgrading.
+
+> See [#25690](https://github.com/abpframework/abp/pull/25690) and [#25734](https://github.com/abpframework/abp/pull/25734) for details.
+
+### Antiforgery and OpenIddict Security Improvements
+
+ABP v10.6 includes several security-focused fixes for mixed authentication scenarios.
+
+**Antiforgery claim issuer normalization**
+
+When an application serves a token-authenticated SPA and cookie-authenticated MVC pages on the same origin, antiforgery validation could fail because the user id claim issuer differed between JWT and cookie authentication schemes. ABP now normalizes the user id claim issuer while generating and validating antiforgery tokens.
+
+This behavior is enabled by default through `AbpAntiForgeryOptions.NormalizeUserIdClaimIssuer`. Razor Pages antiforgery validation was also aligned with the same normalization logic, which fixes failures in modules such as Setting Management.
+
+**Prevent OpenIddict `client_id` from leaking into the interactive auth cookie**
+
+ABP fixed a case where an OpenIddict authorization request could stamp the requested `client_id` into the interactive authentication cookie during security-stamp refresh. That could corrupt audit logs and make later cookie-authenticated requests appear to belong to the OAuth client.
+
+The fix strips `client_id` when the interactive cookie is refreshed. Tokens are unaffected, and cookies that were already corrupted self-heal on the next refresh.
+
+**Forward the current access token for authenticated client requests**
+
+`HttpContextAbpAccessTokenProvider` now forwards the incoming access token whenever the request is authenticated, including `client_credentials` requests. This prevents unnecessary fallback to configured identity clients in machine-to-machine scenarios.
+
+> See [#25655](https://github.com/abpframework/abp/pull/25655), [#25669](https://github.com/abpframework/abp/pull/25669), [#25711](https://github.com/abpframework/abp/pull/25711), and [#25740](https://github.com/abpframework/abp/pull/25740) for details.
+
+### OpenIddict: Generate Access Token from the UI
+
+ABP Commercial v10.6 RC adds a **Generate Access Token** action to OpenIddict application management pages across MVC, Blazor, MudBlazor, and Angular UIs.
+
+Administrators can request a token for an OpenIddict application directly from the UI. The backend forwards a `client_credentials` request to `/connect/token` and returns the generated access token to the caller.
+
+This is especially useful for testing integrations, validating scopes, and troubleshooting machine-to-machine authentication without leaving the admin UI.
+
+### Dependency Updates
+
+ABP v10.6 RC includes several dependency and package updates:
+
+- Angular packages upgraded to **22.0.x**
+- `Microsoft.*` and `System.*` packages upgraded to **10.0.9**
+- `Microsoft.Data.SqlClient` upgraded to **7.0.2**
+- `Swashbuckle.AspNetCore` upgraded to **10.2.3**
+
+> Check the [Package Version Changes](https://abp.io/docs/10.6/package-version-changes) document for all updates.
+
+### Other Improvements and Enhancements
+
+- **Permission management**: Skip dynamic permission initialization during migration runs to avoid noisy logs when the database is unavailable ([#25743](https://github.com/abpframework/abp/pull/25743)).
+- **Security / principal access**: `ThreadCurrentPrincipalAccessor` now returns an anonymous principal instead of `null` in non-web contexts ([#25752](https://github.com/abpframework/abp/pull/25752)).
+- **Angular proxy generation**: Array parameters are now generated as `readonly` in Angular proxies ([#25687](https://github.com/abpframework/abp/pull/25687)).
+- **Date/time normalization**: Removed misleading warnings when normalizing `Unspecified` `DateTime` values near range boundaries ([#25703](https://github.com/abpframework/abp/pull/25703)).
+- **AI Management**: Indexing is more resilient under memory pressure in the commercial module.
+
+## Community News
+
+### New ABP Community Articles
+
+As always, exciting articles have been contributed by the ABP community. I will highlight some of them here:
+
+- [ABP 10.5.0 Expands Blazor UI Options with MudBlazor Support](https://abp.io/community/articles/abp-10.5.0-expands-blazor-ui-options-with-mudblazor-support-03rzmlpm) by [Liming Ma](https://abp.io/community/members/maliming)
+- [Angular 22 State Management: Signals, SignalStore, or NgRx?](https://abp.io/community/articles/angular-22-state-management-signals-signalstore-or-ngrx-yq8zg0nw) by [Sumeyye Kurtulus](https://abp.io/community/members/sumeyye.kurtulus)
+- [Working with Dapr Workflows in the ABP Framework](https://abp.io/community/articles/working-with-dapr-workflows-in-the-abp-framework-6476or18) by [Engincan Veske](https://abp.io/community/members/EngincanV)
+- [My Speaker's View of CONVEX Summit 2026](https://abp.io/community/articles/my-speakers-view-of-convex-summit-2026-ai-net-conference-3uk6ln1l) by [Alper Ebiçoğlu](https://abp.io/community/members/alper)
+
+Thanks to the ABP Community for all the content they have published. You can also [post your ABP related (text or video) content](https://abp.io/community/posts/create) to the ABP Community.
+
+### ABP Summer Campaign: Get Up To 20% Off + $300 in AI Credits
+
+
+
+Summer is a great time to start building with ABP. From **July 6 to July 20**, we're offering exclusive summer savings on **ABP licenses and renewals**: **20% off new licenses**, **10% off renewals**, and **up to $300 in AI credits** for the **ABP AI Agent** in **ABP Studio**. Whether you're starting a new project or upgrading your development workflow, this limited-time offer helps you save on your license while accelerating development with AI.
+
+> You can read the announcement here: [ABP Summer Campaign: Get Up To 20% Off + $300 in AI Credits](https://abp.io/community/announcements/abp-summer-campaign-get-up-to-20-off-300-in-ai-credits-r5lqtpg9).
+
+## Conclusion
+
+This version comes with some new features and a lot of enhancements to the existing features. You can see the [Road Map](https://abp.io/docs/10.6/release-info/road-map) documentation to learn about the release schedule and planned features for the next releases. Please try ABP v10.6 RC and provide feedback to help us release a more stable version.
+
+Thanks for being a part of this community!
\ No newline at end of file
diff --git a/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/cover-image.png b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/cover-image.png
new file mode 100644
index 0000000000..efb4466e5d
Binary files /dev/null and b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/cover-image.png differ
diff --git a/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/studio-switch-to-preview.png b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/studio-switch-to-preview.png
new file mode 100644
index 0000000000..ad73877834
Binary files /dev/null and b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/studio-switch-to-preview.png differ
diff --git a/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/summer-sale.png b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/summer-sale.png
new file mode 100644
index 0000000000..f8c5163e45
Binary files /dev/null and b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/summer-sale.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/designer-hybrid-flow.gif b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/designer-hybrid-flow.gif
new file mode 100644
index 0000000000..e57cf107dc
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/designer-hybrid-flow.gif differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/runtime-workflow.gif b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/runtime-workflow.gif
new file mode 100644
index 0000000000..4bc6a19584
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/runtime-workflow.gif differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/code-backlog-summary.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/code-backlog-summary.png
new file mode 100644
index 0000000000..5df821842e
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/code-backlog-summary.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/custom-endpoint-summary.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/custom-endpoint-summary.png
new file mode 100644
index 0000000000..1622631d45
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/custom-endpoint-summary.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-code-layer.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-code-layer.png
new file mode 100644
index 0000000000..40d12507a2
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-code-layer.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-endpoint.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-endpoint.png
new file mode 100644
index 0000000000..eda5c6b88c
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-endpoint.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-entity.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-entity.png
new file mode 100644
index 0000000000..a683e6cc21
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-entity.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-form.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-form.png
new file mode 100644
index 0000000000..8df3e079d9
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-form.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-page.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-page.png
new file mode 100644
index 0000000000..6ca404b734
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-page.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-modal.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-modal.png
new file mode 100644
index 0000000000..5caf7a8c34
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-modal.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-saved.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-saved.png
new file mode 100644
index 0000000000..727953c4b4
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-saved.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-form-create-modal.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-form-create-modal.png
new file mode 100644
index 0000000000..f1e3f0fa52
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-form-create-modal.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-page-create-before-save.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-page-create-before-save.png
new file mode 100644
index 0000000000..2a68c8c060
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-page-create-before-save.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-review-template-properties.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-review-template-properties.png
new file mode 100644
index 0000000000..b81b1e76e7
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-review-template-properties.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-runtime-entity.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-runtime-entity.png
new file mode 100644
index 0000000000..e1eeccec7e
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-runtime-entity.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-application-properties.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-application-properties.png
new file mode 100644
index 0000000000..a87a00c529
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-application-properties.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-escalation-save-modal.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-escalation-save-modal.png
new file mode 100644
index 0000000000..b56f365486
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-escalation-save-modal.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-form-documents.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-form-documents.png
new file mode 100644
index 0000000000..d73c53ca18
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-form-documents.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-grid-filtered.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-grid-filtered.png
new file mode 100644
index 0000000000..eeb88d0be5
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-grid-filtered.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-review-rejected.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-review-rejected.png
new file mode 100644
index 0000000000..6bb837bd4d
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-review-rejected.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-vendor-escalations.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-vendor-escalations.png
new file mode 100644
index 0000000000..ddcc333f9b
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-vendor-escalations.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/cover.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/cover.png
new file mode 100644
index 0000000000..a6d312b64d
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/cover.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/post.md b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/post.md
new file mode 100644
index 0000000000..e76a154a25
--- /dev/null
+++ b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/post.md
@@ -0,0 +1,394 @@
+# Building a Vendor Onboarding Workflow with ABP Low-Code
+
+Vendor onboarding usually starts with a few familiar steps.
+
+A company sends its details, someone checks the documents, another person reviews the score, and the team either approves the vendor or asks for more information. After a while, the process turns into a mix of spreadsheets, uploaded files, status notes, and "who is waiting on this one?" messages.
+
+In this article, we'll build that workflow with the [Low-Code System](https://abp.io/docs/latest/low-code/index). We'll model the data in the [Low-Code Designer](https://abp.io/docs/latest/low-code/designer), let the [React runtime](https://abp.io/docs/latest/low-code/react-runtime) render the page, and then add one [custom endpoint](https://abp.io/docs/latest/low-code/custom-endpoints) for a summary that does not belong to normal CRUD.
+
+The example is an internal operations page where a team receives vendor applications, reviews compliance documents, tracks deadlines, and follows rejected or priority vendors from one place.
+
+That is a good place to try ABP Low-Code, because the first version of the workflow is mostly data, screens, validation rules, and a few process-specific actions. You do not need to hand-write a React page only to list vendor applications, upload a compliance document, or show a rejection reason when the status is rejected.
+
+We will start from an already running ABP React + EF Core application with Low-Code enabled, so the article can stay focused on the Admin Console, the Designer, and the runtime flow.
+
+## What We Are Building
+
+The workflow has one main record: `VendorApplication`.
+
+A reviewer should be able to:
+
+- Create a vendor application with company and contact details.
+- Track whether the vendor is `Submitted`, `InReview`, `Approved`, or `Rejected`.
+- Set the requested date and approval deadline.
+- Mark priority vendors.
+- Assign a category such as `Software`, `Services`, or `Hardware`.
+- Upload a logo and a compliance document.
+- Fill in a rejection reason only when the application is rejected.
+- Filter the generated grid by status, requested date, priority, and category.
+- Call a summary endpoint that returns counts for dashboard-like use.
+
+We'll also touch two extra pieces around that main record. `VendorReviewTemplate` comes from C# so you can see how code-defined metadata appears in the Designer. Later, a `VendorEscalation` model is added while the Designer is switched to `Runtime JSON`. You could build the whole workflow with one entry point, but using these three entry points makes the hybrid model visible without turning the article into three separate implementations.
+
+## A Quick Note on How Low-Code Fits Together
+
+The Low-Code Designer is where you describe the model and the UI metadata. In this article we use four areas:
+
+- `Data` for enums and entities.
+- `Pages` for the generated grid route.
+- `Forms` for the create/edit form layout.
+- `Actions` for the custom HTTP endpoint.
+
+The Designer stores metadata. The React runtime reads that metadata and renders the page at runtime. That is the important mental model: when we add a field to the entity, the field can become a grid column, a filter, a validation rule, or a form input depending on how we configure the metadata around it.
+
+There is also one database detail to keep in mind. Metadata that comes from C# code or from `Dev JSON` is source-controlled application metadata. When it introduces or changes a persisted entity, run the normal EF Core migration and database update flow before using the generated runtime page. In the validated demo for this article I used SQLite, so the migration updated the local SQLite database. `Runtime JSON` is different: it is authored at runtime, so I do not run a C# migration in that section.
+
+## Add a Code-Defined Review Template
+
+Let's start with one model that does not come from the Designer.
+
+In this workflow, vendor reviewers can use review templates. The template itself is not the center of the workflow, so I kept it focused on the review rules:
+
+```csharp
+[DynamicEnum]
+public enum VendorReviewTemplateType
+{
+ Standard = 0,
+ Security = 1,
+ Finance = 2
+}
+
+[DynamicEntity(DefaultDisplayPropertyName = nameof(Name))]
+[DynamicEntityUI(DisplayName = "Vendor Review Templates")]
+public class VendorReviewTemplate : DynamicEntityBase
+{
+ [Required]
+ [StringLength(128)]
+ [DynamicPropertyUnique]
+ public string Name { get; set; }
+
+ public VendorReviewTemplateType TemplateType { get; set; }
+ public int MinimumComplianceScore { get; set; }
+ public bool RequiresDocumentReview { get; set; }
+ public string? Notes { get; set; }
+}
+```
+
+Then include the entity in your EF Core DbContext. This is the part that makes the migration create a real backing table for the code-defined model:
+
+```csharp
+public DbSet VendorReviewTemplates { get; set; }
+
+builder.Entity(b =>
+{
+ b.ToTable(
+ VendorOnboardingLowCodeConsts.DbTablePrefix + "VendorReviewTemplates",
+ VendorOnboardingLowCodeConsts.DbSchema
+ );
+ b.ConfigureByConvention();
+ b.Property(x => x.Name).IsRequired().HasMaxLength(128);
+ b.Property(x => x.Notes).HasMaxLength(512);
+ b.HasIndex(x => x.Name).IsUnique();
+});
+```
+
+Because this model is defined in C#, treat it like the rest of your application schema changes: add the entity, add the DbSet/mapping, create/apply the EF Core migration, and then start the application.
+
+After the app starts, open **Admin Console > Low-Code Designer > Data**. The model is visible there, but it is read-only because it was defined in code.
+
+
+
+Open the **Properties** tab and you can see the fields that came from the C# class. They are available to the Low-Code System, but the Designer marks them as code-owned.
+
+
+
+That is useful in real projects. Some metadata can be shipped with the application, while the rest of the workflow can still be designed through the Admin Console.
+
+## Create the Vendor Enums
+
+Now move to the part we actually build in the Designer.
+
+The animation below shows the Designer path in one pass. The next sections slow it down and explain the enum, entity, page, and form steps.
+
+
+
+Open `Data > Enums` and create the status enum:
+
+```text
+VendorApplicationStatus
+Submitted
+InReview
+Approved
+Rejected
+```
+
+Before saving, the enum modal should contain the name and the four values:
+
+
+
+Then create the category enum:
+
+```text
+VendorCategory
+Software
+Services
+Hardware
+```
+
+The order of the status values matters for the custom endpoint later, because the script checks the enum values by their numeric indexes. In this example `Submitted` is `0`, `Approved` is `2`, and `Rejected` is `3`.
+
+After saving, the enum detail page shows the numeric values that the runtime and scripts will use:
+
+
+
+## Create the VendorApplication Entity
+
+Go to `Data > Entities` and create `VendorApplication`.
+
+This is the model that drives the rest of the article. Add these fields:
+
+| Field | Type | Configuration |
+| --- | --- | --- |
+| `CompanyName` | `String` | Required and unique |
+| `ContactEmail` | `String` | Required, email validation |
+| `Status` | `Enum` | `VendorApplicationStatus` |
+| `RequestedOn` | `Date` | Application date |
+| `ApprovalDeadline` | `Date` | Review deadline |
+| `IsPriority` | `Boolean` | Priority flag |
+| `Category` | `Enum` | `VendorCategory` |
+| `ComplianceScore` | `Int` | Review score |
+| `Logo` | `Image` | Logo upload |
+| `ComplianceDocument` | `File` | Document upload |
+| `RejectionReason` | `String` | Optional |
+
+
+
+The **Properties** tab is where the entity becomes more than a name. The table shows the field types, enum bindings, and source layer. Scroll down and the upload-related fields are visible with their `Image` and `File` types:
+
+
+
+There is no React code yet, but we already have a lot of behavior described: required fields, uniqueness, email validation, enum fields, upload fields, and the data shape that the runtime will use.
+
+The `Image` and `File` types are worth calling out. They are not plain strings with a path. In the generated form they become upload controls, which is exactly what we need for vendor logos and compliance documents.
+
+Since `VendorApplication` is authored in the `Dev JSON` layer, it also belongs to the source-controlled model. After saving the entity metadata, create/apply the EF Core migration before you open the generated page in the runtime. This is the step that creates the backing table for the low-code entity in the database.
+
+## Generate a Grid Page
+
+The reviewers need a page where they can work with applications, so go to `Pages` and create a `dataGrid` page named `vendor-onboarding`.
+
+Bind it to `VendorApplication`.
+
+Before saving the page, the modal connects the route name, title, icon, and entity:
+
+
+
+After the page is created, set `RequestedOn` as the default sort field, keep it descending, adjust the icon if you want, and assign `vendor-application-form` as the create/edit form:
+
+
+
+For the review workflow, keep the configured columns focused on the fields reviewers use most:
+
+- Company name
+- Status
+- Requested date
+- Priority
+- Category
+
+Then configure the filters you want reviewers to use most often. In this workflow, the important filters are company, status, requested date, priority, and category. Depending on the runtime defaults, the generated grid may still expose additional fields such as contact email; the workflow is still driven by the focused page metadata above.
+
+Once the page is saved, the React runtime can resolve the route from the page metadata. The grid is generated from the entity and page configuration rather than from a hand-written React component.
+
+## Build the Create/Edit Form
+
+A grid is not enough. We also need a form that feels like the workflow.
+
+Go to `Forms` and create `vendor-application-form` for `VendorApplication`. Split the fields into three tabs:
+
+
+
+- **Company**: `CompanyName`, `ContactEmail`, `Category`, `IsPriority`
+- **Review**: `Status`, `RequestedOn`, `ApprovalDeadline`, `ComplianceScore`, `RejectionReason`
+- **Documents**: `Logo`, `ComplianceDocument`
+
+Now add the conditional behavior for `RejectionReason`. In this demo I used two complementary rules: one rule shows the field when `Status = Rejected`, and the other hides it for non-rejected statuses.
+
+
+
+This is one of the places where Low-Code becomes more than "generate a CRUD page". The runtime does more than render a static form; it evaluates the rule while the user edits the record.
+
+## Apply the Migration Before Opening the Runtime
+
+Before opening the generated page, apply the database migration for the `Dev JSON` changes. We used `Dev JSON` for `VendorApplication`, so the Designer wrote source-controlled descriptor files under `_Dynamic`. The entity shape is now part of the application model, and the database needs the matching backing table before the React runtime can save records.
+
+That is why `Dev JSON` is a good fit during development: the metadata files and the EF Core migration can be reviewed, committed, and reproduced in another environment. If the same entity had been created in the `Runtime JSON` layer, you would not create a C# migration for that runtime edit; the metadata change would be stored in the database instead. In practice, use `Dev JSON` for development-time, source-controlled changes, and use `Runtime JSON` when you want production-time changes to be managed from the Admin Console and persisted in the database.
+
+## Try It in the React Runtime
+
+Open the generated `vendor-onboarding` page in the React runtime and create a vendor application.
+
+On the `Documents` tab, the `Logo` and `ComplianceDocument` fields are rendered as upload fields:
+
+
+
+Now edit a record and change the status to `Rejected`. The `RejectionReason` field becomes available on the `Review` tab:
+
+
+
+After saving a few records, use the generated filters to narrow the list to rejected vendors. Depending on the runtime configuration, the filter panel can expose more fields than the small set you configured for the workflow; here we only use the `Status = Rejected` filter:
+
+
+
+The short animation below gives a quick pass through the same runtime states: upload fields, the conditional rejection reason, and the filtered grid.
+
+
+
+At this point we have a working page, form, validation, uploads, and filters. The important part is that all of it came from the metadata we configured in the Designer.
+
+## Add a Custom Summary Endpoint
+
+Generated CRUD is enough for day-to-day record editing, but teams often need one operation that is specific to their process.
+
+For vendor onboarding, a summary endpoint is a good example:
+
+```text
+GET /api/custom/vendor-onboarding/summary
+```
+
+In the Designer, open `Actions` and create a custom HTTP action with that route. The script can use the [Scripting API](https://abp.io/docs/latest/low-code/scripting-api) to query the same `VendorApplication` data that the generated grid uses.
+
+
+
+Here is the script used in the demo:
+
+```js
+var entityName = 'Acme.VendorOnboardingLowCode.Procurement.VendorApplication';
+var vendorQuery = await db.query(entityName);
+var totalVendors = await db.count(entityName);
+var submittedVendors = await vendorQuery.where(x => x.Status === 0).count();
+var approvedVendors = await vendorQuery.where(x => x.Status === 2).count();
+var today = query.today || new Date().toISOString().slice(0, 10);
+var overdueReviews = await vendorQuery
+ .where(x => x.ApprovalDeadline != null && x.ApprovalDeadline < today && x.Status !== 2)
+ .count();
+
+return ok({
+ totalVendors: totalVendors,
+ submittedVendors: submittedVendors,
+ approvedVendors: approvedVendors,
+ overdueReviews: overdueReviews,
+ evaluatedOn: today
+});
+```
+
+Use the entity name shown in your Designer. In the screenshots, it is `Acme.VendorOnboardingLowCode.Procurement.VendorApplication`.
+
+When the endpoint runs, it returns the current counts from the low-code records:
+
+
+
+That is the bridge I like here. The page and form stay metadata-driven, but the process-specific summary is a short script exposed as a custom endpoint.
+
+## Add One Runtime Model
+
+Now switch the Designer layer to `Runtime JSON` and add one more entity: `VendorEscalation`.
+
+This model represents the items that need extra attention. It could have been created in the same place as `VendorApplication`; I am adding it here only to show that runtime-authored metadata participates in the same Low-Code System.
+
+Unlike the code and `Dev JSON` examples above, this runtime-authored model is not part of the source-controlled migration flow in this walkthrough.
+
+The create modal is the same Designer experience, but the selected layer is now `Runtime JSON`:
+
+
+
+
+
+Create a data grid page for it and open it in the React runtime:
+
+
+
+From the user's point of view, it behaves like the first generated page. From the metadata point of view, we have now seen code-defined metadata, Designer-authored metadata, and runtime-authored metadata in the same application.
+
+## Read the Same Data from ABP Code
+
+The last bridge is application code.
+
+Sometimes the generated page is not the only consumer. You may want a typed application service, a scheduled job, or another API to read the same low-code records. The code below shows the idea by returning a backlog summary:
+
+```csharp
+private readonly IRepository _vendorApplicationRepository;
+private readonly IAsyncQueryableExecuter _queryableExecuter;
+
+public async Task GetBacklogAsync()
+{
+ var entityDescriptor = DynamicModelManager.Instance.Find(
+ "Acme.VendorOnboardingLowCode.Procurement.VendorApplication"
+ );
+
+ if (entityDescriptor == null)
+ {
+ throw new UserFriendlyException("VendorApplication model was not found.");
+ }
+
+ var query = await _vendorApplicationRepository
+ .SetEntityName(entityDescriptor.Name)
+ .GetQueryableAsync();
+ var today = DateOnly.FromDateTime(Clock.Now);
+ var priorityQuery = query.Where(vendor =>
+ vendor.Data["IsPriority"] != null &&
+ (bool?)vendor.Data["IsPriority"] == true);
+
+ var nextPriorityVendor = await _queryableExecuter.FirstOrDefaultAsync(
+ priorityQuery.OrderByDescending(vendor =>
+ (DateOnly?)vendor.Data["RequestedOn"]));
+
+ return new VendorBacklogDto
+ {
+ TotalVendors = checked((int)await _queryableExecuter.LongCountAsync(query)),
+ PriorityVendors = checked((int)await _queryableExecuter.LongCountAsync(priorityQuery)),
+ RejectedVendors = checked((int)await _queryableExecuter.LongCountAsync(
+ query.Where(vendor =>
+ vendor.Data["Status"] != null &&
+ (int?)vendor.Data["Status"] == 3))),
+ OverdueReviews = checked((int)await _queryableExecuter.LongCountAsync(
+ query.Where(vendor =>
+ vendor.Data["ApprovalDeadline"] != null &&
+ (DateOnly?)vendor.Data["ApprovalDeadline"] < today &&
+ vendor.Data["Status"] != null &&
+ (int?)vendor.Data["Status"] != 2))),
+ NextPriorityVendor = nextPriorityVendor?.GetData("CompanyName")
+ };
+}
+```
+
+
+
+The important detail is that the aggregate operations stay on `IQueryable`; the code does not load every vendor into memory just to count them. This is not a replacement for the generated page. It is the other direction: use the generated page for the admin experience, then read the same records from normal ABP code when another part of the application needs them.
+
+## Going Further
+
+The workflow we built is intentionally focused, but the same shape can grow in a few directions:
+
+- Add permissions around the generated pages and custom endpoint.
+- Add more form rules for review-specific fields.
+- Add an approval notification after a vendor is accepted.
+- Add a scheduled job that checks overdue applications.
+- Build a dashboard widget on top of the summary endpoint.
+
+The main pattern stays the same: model the data in the Low-Code Designer, let the React runtime render the operational page, and add code or scripting only for the parts that are specific to your business process.
+
+## Conclusion
+
+ABP Low-Code is useful when the first version of a business workflow is mostly metadata: entities, fields, filters, forms, validation, uploads, and a few custom actions.
+
+In this vendor onboarding example, the `VendorApplication` model gave us a generated grid and form, the runtime handled upload fields and conditional UI, and a custom endpoint added the summary that CRUD would not provide by itself. We also saw that low-code metadata can come from the Designer, from runtime JSON, or from C# code when you need that bridge.
+
+That is the part worth remembering: you can start with a working admin experience quickly, then extend the workflow where the generated behavior stops being enough.
+
+### Further Reading
+
+- [Low-Code System Overview](https://abp.io/docs/latest/low-code/index)
+- [Low-Code Designer](https://abp.io/docs/latest/low-code/designer)
+- [React Runtime](https://abp.io/docs/latest/low-code/react-runtime)
+- [Custom Endpoints](https://abp.io/docs/latest/low-code/custom-endpoints)
+- [Scripting API](https://abp.io/docs/latest/low-code/scripting-api)
diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/summary.md b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/summary.md
new file mode 100644
index 0000000000..cc1f78fded
--- /dev/null
+++ b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/summary.md
@@ -0,0 +1 @@
+Build a vendor onboarding workflow with ABP Low-Code: model vendor applications in the Designer, let the React runtime render the grid and form, then add a custom endpoint and a typed ABP code bridge for process-level counts.
diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-calendar-kanban-flow.gif b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-calendar-kanban-flow.gif
new file mode 100644
index 0000000000..031a41a302
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-calendar-kanban-flow.gif differ
diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-custom-endpoint-flow.gif b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-custom-endpoint-flow.gif
new file mode 100644
index 0000000000..7dc75e3980
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-custom-endpoint-flow.gif differ
diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-grid-form-flow.gif b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-grid-form-flow.gif
new file mode 100644
index 0000000000..e9adf0e551
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-grid-form-flow.gif differ
diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-hero-loop.gif b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-hero-loop.gif
new file mode 100644
index 0000000000..551ab661a6
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-hero-loop.gif differ
diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-page-builder.gif b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-page-builder.gif
new file mode 100644
index 0000000000..99f02f0a9c
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-page-builder.gif differ
diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/abp-studio-lowcode-system.png b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/abp-studio-lowcode-system.png
new file mode 100644
index 0000000000..fac98fbf17
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/abp-studio-lowcode-system.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/admin-console-lowcode.png b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/admin-console-lowcode.png
new file mode 100644
index 0000000000..f6d2603eb7
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/admin-console-lowcode.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/overview-dashboard.png b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/overview-dashboard.png
new file mode 100644
index 0000000000..6cc9175a3c
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/overview-dashboard.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/sponsor-activation-form.png b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/sponsor-activation-form.png
new file mode 100644
index 0000000000..e5a22863aa
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/sponsor-activation-form.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/cover.png b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/cover.png
new file mode 100644
index 0000000000..78819c4b8b
Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/cover.png differ
diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/post.md b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/post.md
new file mode 100644
index 0000000000..f74b0b0a20
--- /dev/null
+++ b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/post.md
@@ -0,0 +1,225 @@
+# Introducing ABP Low-Code: Build Real ABP Apps in Minutes
+
+**Create runtime-managed pages, generated React screens, code-first C# entities, and Script API extensions without leaving the ABP application model.**
+
+
+
+> **Want to try the same path?** Start from ABP Studio, enable the Low-Code runtime and designer, define pages in the Admin Console, and see them resolve inside the running ABP app.
+
+---
+
+## ABP Low-Code at a glance
+
+| Runtime authoring | Generated screens | ABP-native extensibility | One application model |
+| :---: | :---: | :---: | :---: |
+| Define and update pages in the Admin Console | Grid, Form, Calendar, Kanban, Gallery, Dashboard | Code-first entities, Script API actions, and C# query paths | Runtime metadata, generated UI, and application code stay together |
+
+---
+
+## Built into the ABP Platform
+
+Low-code is most useful when speed does not create a separate stack to maintain later.
+
+That is where many low-code products start to strain. They move quickly at the beginning, then force a second implementation track when the app needs permissions, auditability, custom logic, or tighter integration with existing application code.
+
+ABP Low-Code takes a different path. It runs **inside the ABP Platform**, so runtime-managed pages are part of an application foundation that already includes identity, permissions, audit logging, APIs, and code-level extensibility.
+
+---
+
+## Edit at runtime. See it in the app.
+
+In the Low-Code Designer, you update a runtime-managed page. A few seconds later, the same application surface is visible in the live app. No rebuild loop. No parallel front-end implementation. No "we will wire it later" gap between authoring and runtime.
+
+ABP Low-Code shortens the cycle from model change to running screen while keeping the output grounded in the same ABP application.
+
+
+
+> **What this shows:** authoring and runtime are connected. Pages are defined in the designer and resolved in the running application.
+
+---
+
+## CRUD is table stakes
+
+If low-code only saves you from drawing a table and a form, it is not enough. Business applications need richer operational surfaces.
+
+In the generated app, the `Events` screen ships with search, actions, filters, and form-driven editing. The form structure already understands tabs, relations, validation, and business-shaped input instead of leaving you with a blank shell to finish by hand.
+
+
+
+The point is not just generated CRUD. It is generated CRUD that already looks like the operational screens teams maintain in real applications.
+
+---
+
+## One model, multiple operational screens
+
+Business users do not think in one view. Operators want a calendar for scheduling, a kanban board for workflow, a grid for bulk operations, a gallery when media matters, and a dashboard when they need the state of the business at a glance.
+
+ABP Low-Code keeps those surfaces attached to the same underlying model. The same `Session` model can appear as a **calendar** for planning and a **kanban pipeline** for operational flow. The same generated app can also include a **speaker gallery** and an **overview dashboard** for metrics.
+
+
+
+
+
+This is where ABP Low-Code starts to feel less like a form generator and more like a runtime application layer: one model, many working screens, no second implementation track for each view type.
+
+---
+
+## When low-code needs code
+
+The real differentiator is not that ABP Low-Code can go fast. It is that **speed does not require isolation from the application foundation**.
+
+When generated CRUD is not enough, you extend the same app instead of throwing the low-code layer away.
+
+ABP Low-Code exposes a server-side **Script API** inside the same application model. That scripting surface can back:
+
+- **Custom endpoints** when the UI needs an API-shaped response.
+- **Interceptors** when create or update commands need validation or mutation.
+- **Event handlers** when logic should react to runtime events.
+- **Background jobs** when work should continue asynchronously.
+- **Background workers** when operational logic should run on a schedule.
+
+In this article, the visible proof happens to be `GET /api/custom/eventflow/highlights`. The GIF shows an endpoint because it is the easiest proof surface to read. But the broader point is that endpoints are only one consumer of the same low-code scripting layer.
+
+That hybrid model matters in both directions:
+
+- **Code-first ABP entities can be surfaced in low-code flows and runtime pages.**
+- **Low-code-managed data and screens stay reachable from Script API actions, application services, repository queries, and custom endpoints.**
+- **Teams do not lose architectural control just because they gained a faster authoring layer.**
+
+
+
+The actual capability is the shared ABP application model behind it: script when runtime logic is enough, C# when typed application services and repository queries are the better fit.
+
+This is the difference between "low-code as a shortcut" and "low-code as part of your application platform."
+
+---
+
+## From code-first entity to generated page
+
+The first direction is code-first to low-code. A **code-first** `SponsorActivation` entity checked into the ASP.NET Core project can still become a working runtime page without forking into a separate low-code-only model.
+
+The code-first entity carries the same metadata that ABP Low-Code uses to generate the page:
+
+```csharp
+[DynamicEntity(DefaultDisplayPropertyName = nameof(CompanyName))]
+[DynamicEntityUI("Sponsor Activations")]
+[DynamicEntityAttachments("application/pdf", "image/*", MaxFileCount = 4)]
+public class SponsorActivation : DynamicEntityBase
+{
+ [Required]
+ [DynamicPropertyUI(DisplayName = "Sponsor")]
+ public string CompanyName { get; private set; }
+
+ [Required]
+ [EmailAddress]
+ [DynamicPropertyUI(DisplayName = "Contact Email")]
+ public string ContactEmail { get; private set; }
+
+ public SponsorActivationStatus Status { get; set; }
+
+ [DynamicForeignKey("EventFlow.Events.Event", "Title")]
+ public Guid? EventId { get; set; }
+
+ [DynamicForeignKey("Volo.Abp.Identity.IdentityUser", nameof(IdentityUser.UserName), ForeignAccess.View)]
+ public Guid? OwnerUserId { get; set; }
+
+ [DynamicPropertyType(EntityPropertyType.Money)]
+ public decimal ActivationBudget { get; set; }
+
+ [DynamicPropertyImageOptions("image/png", "image/jpeg")]
+ public string? BrandLogo { get; set; }
+
+ [DynamicPropertyFileOptions("application/pdf", ".pptx", ".docx")]
+ public string? ActivationBrief { get; set; }
+}
+```
+
+That class lives as normal C# source, gets migrated like the rest of the application, and is seeded with real records so the runtime page does not open as an empty shell.
+
+Inside the designer, selecting the `SponsorActivation` entity auto-generates the page identity, binds the grid to the entity, and lands on a real runtime route at `/dynamic/sponsor-activation`. The generated surface includes sponsor, email, event lookup, owner lookup, budget, image, and file fields directly from the C# model.
+
+
+
+
+
+That is the distinction that matters: code-first ABP entities can move through low-code without becoming throwaway artifacts, and low-code-generated surfaces remain part of the same application story.
+
+---
+
+## Low-code data stays reachable from C#
+
+The bridge also works in the other direction. A normal ABP application service can query a low-code model through `IRepository`, apply real filters, and combine that result with code-first aggregates.
+
+The service behind the endpoint in the previous section looks like this:
+
+```csharp
+public async Task GetHybridSummaryAsync()
+{
+ var liveSessionQuery = (await _dynamicEntityRepository
+ .SetEntityName("EventFlow.Events.Session")
+ .GetQueryableAsync())
+ .Where("int(it[\"Status\"]) == @0", 2);
+
+ var publicSessionQuery = liveSessionQuery
+ .Where("bool(it[\"IsPublic\"]) == @0", true);
+
+ var sponsorQuery = (await _sponsorActivationRepository.GetQueryableAsync())
+ .Where(activation =>
+ activation.Status == SponsorActivationStatus.Approved ||
+ activation.Status == SponsorActivationStatus.Live);
+
+ var liveSessionCount = await AsyncExecuter.CountAsync(liveSessionQuery);
+ var publicSessionCount = await AsyncExecuter.CountAsync(publicSessionQuery);
+ var activeSponsorActivationCount = await AsyncExecuter.CountAsync(sponsorQuery);
+
+ return new EventFlowLowCodeProofDto
+ {
+ LiveSessionCount = liveSessionCount,
+ PublicSessionCount = publicSessionCount,
+ ActiveSponsorActivationCount = activeSponsorActivationCount
+ };
+}
+```
+
+Here, low-code-managed `Session` rows are filtered from C# with real `Where(...)` clauses, then combined with the typed `SponsorActivation` repository. The endpoint and dashboard are just one presentation surface for that shared ABP query path.
+
+That is the ABP difference: low-code data stays reachable from code, and code-first entities stay reachable from low-code.
+
+---
+
+## Why ABP Low-Code matters
+
+The value is not novelty. It is a faster way to build real business applications without separating speed from the application foundation.
+
+- **Speed without replatforming.** Runtime-managed screens reduce delivery time without moving the team onto a separate application stack.
+- **Governance without friction.** Permissions, identity, auditability, and ABP platform foundations stay part of the story from day one.
+- **Extensibility without rewrite pressure.** When custom behavior shows up, the same application can be extended instead of replacing the low-code output.
+
+That is the core ABP Low-Code promise: faster delivery, still inside the application model you can extend.
+
+---
+
+## Try it yourself
+
+The public starting point for ABP Low-Code is **ABP Studio**.
+
+
+
+1. Open **ABP Studio** and create a new solution.
+2. In the solution wizard, enable **Include Low-Code runtime and designer**.
+3. Complete the wizard, then run the generated backend and React UI from the solution.
+4. Sign in with the administrator account created for that solution.
+5. Open **Admin Console** to define runtime-managed entities, forms, pages, permissions, endpoints, and script actions.
+6. Switch to the application side to see those changes resolve live in the running app.
+
+---
+
+## Further reading
+
+- [ABP Low-Code Designer Documentation](https://abp.io/docs/latest/low-code/designer)
+- [ABP Low-Code Configuration & Fluent API](https://abp.io/docs/latest/low-code/fluent-api)
+- [ABP Low-Code Scripting API](https://abp.io/docs/latest/low-code/scripting-api)
+- [ABP Low-Code Script Actions](https://abp.io/docs/latest/low-code/script-actions)
+- [ABP Low-Code Interceptors](https://abp.io/docs/latest/low-code/interceptors)
+- [ABP Studio Documentation](https://abp.io/docs/latest/studio)
+- [Get Started with ABP: Creating a Layered Web Application](https://abp.io/docs/latest/get-started/layered-web-application)
diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/summary.md b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/summary.md
new file mode 100644
index 0000000000..b782a568a7
--- /dev/null
+++ b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/summary.md
@@ -0,0 +1 @@
+Discover how ABP Low-Code blends runtime page building with code-first entities, C# queries, and extensible application logic.
diff --git a/docs/en/framework/api-development/auto-controllers.md b/docs/en/framework/api-development/auto-controllers.md
index 2c6f0ee39a..9591e544ed 100644
--- a/docs/en/framework/api-development/auto-controllers.md
+++ b/docs/en/framework/api-development/auto-controllers.md
@@ -62,6 +62,8 @@ ABP uses a naming convention while determining the HTTP method for a service met
If you need to customize HTTP method for a particular method, then you can use one of the standard ASP.NET Core attributes ([HttpPost], [HttpGet], [HttpPut]... etc.). This requires to add [Microsoft.AspNetCore.Mvc.Core](https://www.nuget.org/packages/Microsoft.AspNetCore.Mvc.Core) nuget package to your project that contains the service.
+The naming convention doesn't map the HTTP QUERY method (a safe method that carries its parameters in the request body, useful when a GET request would have too many query string parameters). If you want to expose an action as a QUERY endpoint, use the `[AcceptVerbs("QUERY")]` attribute explicitly. Such an action is treated as a safe method, so it is not audited and doesn't start a transactional unit of work by default, just like a GET request. However, unlike a GET request, a QUERY request still requires the anti-forgery token because it carries a request body. This is consistent with ASP.NET Core, which doesn't treat QUERY as an anti-forgery exempt method.
+
### Route
Route is calculated based on some conventions:
diff --git a/docs/en/framework/architecture/domain-driven-design/unit-of-work.md b/docs/en/framework/architecture/domain-driven-design/unit-of-work.md
index 697d7dba80..0f50be8f66 100644
--- a/docs/en/framework/architecture/domain-driven-design/unit-of-work.md
+++ b/docs/en/framework/architecture/domain-driven-design/unit-of-work.md
@@ -38,10 +38,10 @@ All of these are automatically handled by the ABP.
While the section above explains the UOW as it is database transaction, actually a UOW doesn't have to be transactional. By default;
-* **HTTP GET** requests don't start a transactional UOW. They still starts a UOW, but **doesn't create a database transaction**.
+* **HTTP GET** and **HTTP QUERY** requests don't start a transactional UOW. They still start a UOW, but **don't create a database transaction**.
* All other HTTP request types start a UOW with a database transaction, if database level transactions are supported by the underlying database provider.
-This is because an HTTP GET request doesn't (and shouldn't) make any change in the database. You can change this behavior using the options explained below.
+This is because they are safe HTTP methods that don't (and shouldn't) make any change in the database. You can change this behavior using the options explained below.
## Default Options
diff --git a/docs/en/framework/infrastructure/artificial-intelligence/index.md b/docs/en/framework/infrastructure/artificial-intelligence/index.md
index ddf9ad4b07..f6b00b07ec 100644
--- a/docs/en/framework/infrastructure/artificial-intelligence/index.md
+++ b/docs/en/framework/infrastructure/artificial-intelligence/index.md
@@ -10,6 +10,8 @@ ABP Framework provides integration for AI capabilities to your application by us
ABP introduces a concept called **AI Workspace**. A workspace allows you to configure isolated AI configurations for a named scope. You can then resolve AI services for a specific workspace when you need to use them.
+If you want to see AI-assisted delivery used on a real application, take a look at [Hanova & Habitly](../../../samples/index.md#hanova--habitly), which were built with the ABP Studio AI Agent.
+
> ABP Framework can work with any AI library or framework that supports .NET development. However, the AI integration features explained in the following documents provide a modular and standard way to work with AI, which allows ABP developers to create reusable modules and components with AI capabilities in a standard way.
## Installation
@@ -37,4 +39,3 @@ Check the following documentation to learn how to use these libraries with the A
- [ABP Microsoft.Extensions.AI integration](./microsoft-extensions-ai.md)
- [ABP Microsoft.Agents.AI (Agent Framework) integration](./microsoft-agent-framework.md)
- [ABP Microsoft.SemanticKernel integration](./microsoft-semantic-kernel.md)
-
diff --git a/docs/en/framework/infrastructure/audit-logging.md b/docs/en/framework/infrastructure/audit-logging.md
index ee2cdf7e41..9dc7618281 100644
--- a/docs/en/framework/infrastructure/audit-logging.md
+++ b/docs/en/framework/infrastructure/audit-logging.md
@@ -48,7 +48,7 @@ Here, a list of the options you can configure:
* `IsEnabledForAnonymousUsers` (default: `true`): If you want to write audit logs only for the authenticated users, set this to `false`. If you save audit logs for anonymous users, you will see `null` for `UserId` values for these users.
* `AlwaysLogOnException` (default: `true`): If you set to true, it always saves the audit log on an exception/error case without checking other options (except `IsEnabled`, which completely disables the audit logging).
* `IsEnabledForIntegrationService` (default: `false`): Audit Logging is disabled for [integration services](../api-development/integration-services.md) by default. Set this property as `true` to enable it.
-* `IsEnabledForGetRequests` (default: `false`): HTTP GET requests should not make any change in the database normally and audit log system doesn't save audit log objects for GET request. Set this to `true` to enable it also for the GET requests.
+* `IsEnabledForGetRequests` (default: `false`): Safe HTTP methods (GET, HEAD and QUERY) should not make any change in the database normally and the audit log system doesn't save audit log objects for these requests. Set this to `true` to enable it also for the safe requests.
* `DisableLogActionInfo` (default: `false`):If you set to true, Will no longer log `AuditLogActionInfo`.
* `ApplicationName`: If multiple applications are saving audit logs into a single database, set this property to your application name, so you can distinguish the logs of different applications. If you don't set, it will set from the `IApplicationInfoAccessor.ApplicationName` value, which is the entry assembly name by default.
* `IgnoredTypes`: A list of `Type`s to be ignored for audit logging. If this is an entity type, changes for this type of entities will not be saved. This list is also used while serializing the action parameters.
diff --git a/docs/en/framework/infrastructure/background-jobs/index.md b/docs/en/framework/infrastructure/background-jobs/index.md
index cdc4a1b5fb..57bffefbe0 100644
--- a/docs/en/framework/infrastructure/background-jobs/index.md
+++ b/docs/en/framework/infrastructure/background-jobs/index.md
@@ -225,7 +225,7 @@ ABP includes a simple `IBackgroundJobManager` implementation that;
- **Retries** job execution until the job **successfully runs** or **timeouts**. Default timeout is 2 days for a job. Logs all exceptions.
- **Deletes** a job from the store (database) when it's successfully executed. If it's timed out, it sets it as **abandoned** and leaves it in the database.
- **Increasingly waits between retries** for a job. It waits 1 minute for the first retry, 2 minutes for the second retry, 4 minutes for the third retry and so on.
-- **Polls** the store for jobs in fixed intervals. It queries jobs, ordering by priority (asc) and then by try count (asc).
+- **Polls** the store for jobs in fixed intervals. It queries jobs, ordering by priority (desc) and then by try count (asc).
> `Volo.Abp.BackgroundJobs` nuget package contains the default background job manager and it is installed to the startup templates by default.
@@ -248,11 +248,76 @@ public class MyModule : AbpModule
````
* `JobPollPeriod` is used to determine the interval between two job polling operations. Default is 5000 ms (5 seconds).
-* `MaxJobFetchCount` is used to determine the maximum job count to fetch in a single polling operation. Default is 1000.
+* `MaxJobFetchCount` is used to determine the maximum job count to fetch in a single polling operation. It is also used as the batch size for the retention cleanup deletions. Default is 1000.
* `DefaultFirstWaitDuration` is used to determine the duration to wait before the first retry. Default is 60 seconds.
* `DefaultTimeout` is used to determine the timeout duration for a job. Default is 172800 seconds (2 days).
* `DefaultWaitFactor` is used to determine the factor to increase the wait duration between retries. Default is 2.0.
* `DistributedLockName` is used to determine the distributed lock name to use. Default is `AbpBackgroundJobWorker`.
+* `StoreSuccessfulJobs` is used to determine whether to keep successfully completed jobs in the store instead of deleting them. Default is `false`. See the *Storing Successful Jobs* section.
+* `SuccessfulJobRetentionTime` is used to determine how long a kept job is retained before the cleanup deletes it. Default is 7 days. Set to `null` to keep completed jobs forever. Only relevant when `StoreSuccessfulJobs` is enabled.
+* `CleanSuccessfulJobsPeriod` is used to determine the interval between cleanup runs that delete expired completed jobs. Default is 3600000 ms (1 hour).
+* `CleanupDistributedLockName` is used to determine the distributed lock name for the cleanup worker. Default is `AbpBackgroundJobCleanup`.
+* `MaxParallelJobExecutionCount` is used to determine the maximum number of jobs a worker executes in parallel within one poll cycle. Default is 1. See the *Parallel Job Execution* section.
+* `PerJobDistributedLockPrefix` is used to determine the prefix of the per-job distributed lock name used when `MaxParallelJobExecutionCount` is greater than 1. Default is `AbpBackgroundJob:`.
+
+### Storing Successful Jobs
+
+By default, the background job manager deletes a job from the store as soon as it runs successfully. If you want to keep completed jobs (for auditing or history), enable `StoreSuccessfulJobs`:
+
+````csharp
+Configure(options =>
+{
+ options.StoreSuccessfulJobs = true;
+ options.SuccessfulJobRetentionTime = TimeSpan.FromDays(30); //null to keep forever
+});
+````
+
+When enabled, a successful job is not deleted; instead its `CompletionTime` is set and it stays in the store. Completed jobs are excluded from the waiting jobs query, so they are not executed again. A cleanup worker periodically deletes completed jobs older than `SuccessfulJobRetentionTime`.
+
+> **Note:** The `IBackgroundJobStore` interface has new overloads (a `GetWaitingJobsAsync` overload that takes a job name filter and a `DeleteAsync` overload for cleanup). If you have a custom `IBackgroundJobStore` implementation, you must implement them for your code to compile. The built-in stores already implement them.
+
+### Dedicated Workers per Job Type
+
+By default, a single worker processes all job types. If you want to process certain job types separately (for example, slow or high-volume jobs), you can register dedicated workers, each handling only the specified job argument types with its own distributed lock:
+
+````csharp
+Configure(options =>
+{
+ options.AddDedicatedWorker("NotificationWorkerLock");
+ options.AddDedicatedWorker("ReportWorkerLock");
+});
+````
+
+Each dedicated worker processes only its configured job types. An additional default worker is automatically started to process all the remaining job types. In sequential mode, each worker (including the default one) runs independently under its own distributed lock (see *Parallel Job Execution* for how this changes when running jobs in parallel).
+
+If you don't want to specify a lock name, use the overloads without the `lockName` parameter; a stable, length-bounded lock name is then derived from the job argument types:
+
+````csharp
+Configure(options =>
+{
+ options.AddDedicatedWorker();
+ options.AddDedicatedWorker();
+});
+````
+
+> **Note:** Each job type can be handled by only one dedicated worker, and each worker must have a unique lock name; `AddDedicatedWorker` throws if this is violated. Dedicated workers require an `IBackgroundJobStore` that can filter jobs by name (the built-in stores can).
+
+### Parallel Job Execution
+
+By default, a worker executes waiting jobs one by one under a single worker-level distributed lock, so only one job runs at a time across all application instances. If you want to execute multiple jobs concurrently, set `MaxParallelJobExecutionCount` to a value greater than 1:
+
+````csharp
+Configure(options =>
+{
+ options.MaxParallelJobExecutionCount = 4;
+});
+````
+
+When it is greater than 1, the worker-level lock is not used. Instead, each job is claimed with its own distributed lock, so multiple application instances can execute different jobs at the same time. With a properly configured distributed lock provider, a job is not executed by more than one instance at a time.
+
+`MaxParallelJobExecutionCount` is a per-worker, per-poll-cycle limit — it is not a cluster-wide limit. A worker first fetches up to `MaxJobFetchCount` waiting jobs, then executes up to `MaxParallelJobExecutionCount` of them in parallel, so a single worker runs up to `min(MaxJobFetchCount, MaxParallelJobExecutionCount)` jobs per cycle; keep `MaxJobFetchCount` at least as large as `MaxParallelJobExecutionCount` to avoid capping the parallelism. When you also configure dedicated workers, each worker runs its own timer and claims up to `MaxParallelJobExecutionCount` jobs, so the effective concurrency is up to (number of workers) × `MaxParallelJobExecutionCount` per application instance, and up to (number of application instances) × (number of workers) × `MaxParallelJobExecutionCount` across the whole cluster.
+
+> **Important:** Configure `MaxParallelJobExecutionCount` and `PerJobDistributedLockPrefix` consistently across all application instances. Mixing sequential (worker lock) and parallel (per-job lock) instances removes the common mutual exclusion, and a different prefix produces a different per-job lock name for the same job — either case may let the same job run on more than one instance. As with the sequential mode, configure a real [distributed lock](../distributed-locking.md) provider for clustered deployments.
### Data Store
diff --git a/docs/en/framework/ui/angular/quick-start.md b/docs/en/framework/ui/angular/quick-start.md
index 8a9f6cb7b4..ef9a626803 100644
--- a/docs/en/framework/ui/angular/quick-start.md
+++ b/docs/en/framework/ui/angular/quick-start.md
@@ -1,19 +1,19 @@
```json
//[doc-seo]
{
- "Description": "Learn how to set up your development environment for ABP Angular 21.x with this quick start guide, ensuring a smooth development experience."
+ "Description": "Learn how to set up your development environment for ABP Angular 22.0.x with this quick start guide, ensuring a smooth development experience."
}
```
# ABP Angular Quick Start
-**In this version ABP uses Angular [21.2.x](https://github.com/angular/angular/tree/21.2.x) version. You don't have to install Angular CLI globally**
+**In this version ABP uses Angular [22.0.x](https://github.com/angular/angular/tree/22.0.x) version. You don't have to install Angular CLI globally**
## How to Prepare Development Environment
Please follow the steps below to prepare your development environment for Angular.
-1. **Install Node.js:** Please visit [Node.js downloads page](https://nodejs.org/en/download/) and download proper Node.js `v20.19+` installer for your OS. An alternative is to install [NVM](https://github.com/nvm-sh/nvm) and use it to have multiple versions of Node.js in your operating system.
+1. **Install Node.js:** Please visit [Node.js downloads page](https://nodejs.org/en/download/) and download proper Node.js `^22.22.3 || ^24.15.0 || ^26.0.0` installer for your OS. An alternative is to install [NVM](https://github.com/nvm-sh/nvm) and use it to have multiple versions of Node.js in your operating system.
2. **[Optional] Install Yarn:** You may install Yarn v1.22+ (not v2) following the instructions on [the installation page](https://classic.yarnpkg.com/en/docs/install). Yarn v1 delivers an arguably better developer experience compared to npm v10 and below. You may skip this step and work with npm, which is built-in in Node.js, instead.
3. **[Optional] Install VS Code:** [VS Code](https://code.visualstudio.com/) is a free, open-source IDE which works seamlessly with TypeScript. Although you can use any IDE including Visual Studio or Rider, VS Code will most likely deliver the best developer experience when it comes to Angular projects. ABP project templates even contain plugin recommendations for VS Code users, which VS Code will ask you to install when you open the Angular project folder. Here is a list of recommended extensions:
- [Angular Language Service](https://marketplace.visualstudio.com/items?itemName=angular.ng-template)
diff --git a/docs/en/framework/ui/angular/release-notes/angular-22-typescript-6.md b/docs/en/framework/ui/angular/release-notes/angular-22-typescript-6.md
new file mode 100644
index 0000000000..aafd588b5b
--- /dev/null
+++ b/docs/en/framework/ui/angular/release-notes/angular-22-typescript-6.md
@@ -0,0 +1,116 @@
+```json
+//[doc-seo]
+{
+ "Description": "Upgrade your ABP solutions to Angular version 22.0.x"
+}
+```
+
+# Release Notes: Angular 22 and TypeScript 6 Upgrade
+
+## Overview
+
+This release updates ABP Angular UI applications to:
+
+* Angular `22.x`
+* TypeScript `6.x`
+
+This upgrade aligns ABP projects with the latest Angular ecosystem and provides access to the newest framework improvements while ensuring long-term maintainability and support.
+
+## What's Changed
+
+### 1. Frontend Stack Upgrades
+
+The core frontend stack has been updated:
+
+* `@angular/*` packages have been upgraded to version 22
+* `typescript` has been upgraded to version 6
+* ABP and ABP Commercial npm packages must be upgraded to the corresponding ABP release line (version 10.6)
+
+### 2. Change Detection Behavior
+
+Angular 22 introduces updated change detection behavior.
+
+* Components without explicit change detection configuration now follow OnPush-style behavior by default
+* Some existing pages may no longer update automatically after asynchronous operations
+* UI state should be managed using Angular Signals or the `async` pipe where appropriate
+
+### 3. Stricter Type and Template Checks
+
+Angular 22 and TypeScript 6 introduce additional compile-time validations.
+
+* More template and type-related issues may be reported during builds
+* Existing assumptions around nullable values and optional properties may require additional guards or type refinements
+* Applications with strict template checking enabled may require code updates
+
+### 4. Upload Progress Handling
+
+Applications that rely on file upload progress events may require additional HTTP client configuration.
+
+* Browser-side HTTP configuration may need `withXhr()` enabled to ensure upload progress events are emitted correctly
+
+### 5. Chart Update Behavior
+
+Chart components may require additional updates when used with asynchronous data sources.
+
+* Under OnPush-style change detection, chart updates may not be detected automatically
+* Consider using Signals for chart data bindings
+* Calling `reinit()` after asynchronous data updates may be necessary in some scenarios
+
+## Required Actions
+
+### 1. Upgrade Related Packages Together
+
+Keep Angular, TypeScript, ABP, and ABP Commercial packages on compatible versions.
+
+To use Angular 22:
+
+* Angular: `22.x`
+* TypeScript: `6.x`
+* ABP Framework: `10.6.x`
+
+### 2. Review UI State Management
+
+Review pages that depend on asynchronous state updates, including:
+
+* List and table data
+* Loading and busy indicators
+* Modal dialog state
+* Dashboard and chart data
+
+Consider migrating these scenarios to Angular Signals or the `async` pipe.
+
+### 3. Apply a Temporary TypeScript Compatibility Setting (If Needed)
+
+If your project uses `downlevelIteration`, you may temporarily add the following configuration:
+
+```json
+{
+ "ignoreDeprecations": "6.0"
+}
+```
+
+This can help ease the migration process while addressing TypeScript 6 deprecation warnings.
+
+### 4. Perform Regression Testing
+
+We recommend validating all critical application flows after upgrading, including:
+
+* Authentication and account management pages
+* CRUD list and detail pages
+* Permission and feature management dialogs
+* File upload workflows
+* Dashboard and chart components
+
+## Areas to Validate Carefully
+
+Pay particular attention to the following scenarios:
+
+* Busy or loading indicators not updating correctly
+* Modal open/close state inconsistencies
+* List pages not refreshing after asynchronous operations
+* Upload progress events not being emitted
+* Charts rendering without data after API responses
+
+## References
+
+* Detailed migration guide: [Upgrade ABP to 10.6](../../../../release-info/migration-guides/abp-10-6-angular-22.md)
diff --git a/docs/en/framework/ui/react-native/index.md b/docs/en/framework/ui/react-native/index.md
index def2ac125c..9f52612326 100644
--- a/docs/en/framework/ui/react-native/index.md
+++ b/docs/en/framework/ui/react-native/index.md
@@ -69,6 +69,8 @@ abp new MyCompanyName.MyProjectName -csf -u -m react-native
This command creates a solution containing an **Angular** or **MVC** project (depending on your choice), a **.NET Core** project, and a **React Native** project.
+If you want to see the modern React Native template as a finished product, the [Habitly sample](../../../samples/index.md#hanova--habitly) is a good reference point.
+
## Run the Application
You can choose how you want to run the mobile app:
diff --git a/docs/en/modules/background-jobs.md b/docs/en/modules/background-jobs.md
index 971284b7c9..2d57d0c907 100644
--- a/docs/en/modules/background-jobs.md
+++ b/docs/en/modules/background-jobs.md
@@ -33,6 +33,8 @@ Following custom repositories are defined for this module:
- `IBackgroundJobRepository`
+> `IBackgroundJobRepository` supports filtering the waiting jobs for dedicated workers and cleaning up retained completed jobs. See the *Dedicated Workers per Job Type* and *Storing Successful Jobs* sections of the [background jobs](../framework/infrastructure/background-jobs) document.
+
### Database providers
#### Common
diff --git a/docs/en/package-version-changes.md b/docs/en/package-version-changes.md
index 431d012e59..8865db1073 100644
--- a/docs/en/package-version-changes.md
+++ b/docs/en/package-version-changes.md
@@ -64,6 +64,7 @@
| Microsoft.IdentityModel.JsonWebTokens | 8.16.0 | 8.19.1 | #25706 |
| Microsoft.IdentityModel.Protocols.OpenIdConnect | 8.16.0 | 8.19.1 | #25706 |
| Microsoft.IdentityModel.Tokens | 8.16.0 | 8.19.1 | #25706 |
+| MongoDB.Driver | 3.9.0 | 3.10.0 | #25773 |
| System.Collections.Immutable | 10.0.7 | 10.0.9 | #25706 |
| System.IdentityModel.Tokens.Jwt | 8.16.0 | 8.19.1 | #25706 |
| System.Management | 10.0.7 | 10.0.9 | #25706 |
@@ -73,6 +74,12 @@
| System.Text.Encodings.Web | 10.0.7 | 10.0.9 | #25706 |
| System.Text.Json | 10.0.7 | 10.0.9 | #25706 |
+## 10.5.1
+
+| Package | Old Version | New Version | PR |
+|---------|-------------|-------------|-----|
+| Swashbuckle.AspNetCore | 10.0.1 | 10.2.3 | #25759 |
+
## 10.5.0-rc.4
| Package | Old Version | New Version | PR |
diff --git a/docs/en/release-info/migration-guides/abp-10-6-angular-22.md b/docs/en/release-info/migration-guides/abp-10-6-angular-22.md
new file mode 100644
index 0000000000..8da4026c96
--- /dev/null
+++ b/docs/en/release-info/migration-guides/abp-10-6-angular-22.md
@@ -0,0 +1,193 @@
+```json
+//[doc-seo]
+{
+ "Description": "Upgrade your ABP solutions to Angular version 22.0.x"
+}
+```
+
+# Angular 22 and ABP 10.6 Upgrade Guide
+
+This guide explains how to upgrade ABP Angular applications to **Angular 22** and **TypeScript 6**.
+
+## 1. Target Versions
+
+Update all frontend dependencies together to maintain compatibility:
+
+- `@angular/*` → `~22.0.0`
+- `typescript` → `~6.0.0`
+- `@abp/*` → corresponding ABP version (10.6)
+- `@volo/*`, `@volosoft/*` (if applicable) → corresponding ABP version (10.6)
+- `angular-oauth2-oidc` (if applicable) → `~22.0.0`
+
+Avoid mixing Angular 21 and Angular 22 packages within the same workspace.
+
+## 2. Prerequisites
+
+Before starting the upgrade:
+
+1. Use a Node.js version supported by Angular 22.
+2. Ensure your backend ABP version is compatible with the frontend package versions you plan to install.
+3. Commit or back up your current project state.
+
+## 3. Upgrade Process
+
+1. Update package versions in `package.json`.
+2. Run the Angular or Nx migration commands applicable to your project.
+3. Remove existing installation artifacts:
+ - Delete `node_modules`
+ - Delete the lock file (`package-lock.json`, `yarn.lock`, or `pnpm-lock.yaml`)
+
+4. Reinstall all dependencies.
+5. Build the application and resolve any compilation or template errors.
+
+## 4. Required Changes
+
+### 4.1 TypeScript 6 Deprecation Handling
+
+Projects that still use `downlevelIteration: true` may encounter TypeScript 6 deprecation diagnostics.
+
+Add the following temporary setting to your root `tsconfig` file (and library production configurations if required):
+
+```json
+{
+ "compilerOptions": {
+ "downlevelIteration": true,
+ "ignoreDeprecations": "6.0"
+ }
+}
+```
+
+As a long-term solution, remove `downlevelIteration` when your target runtime environment no longer requires it.
+
+### 4.2 Updated Change Detection Behavior
+
+Angular 22 introduces updated change detection behavior for components that do not explicitly configure a change detection strategy.
+
+Common symptoms include:
+
+- Loading indicators not updating
+- Modal busy states not clearing
+- Lists or charts not refreshing after asynchronous operations
+
+Recommended approaches:
+
+1. Use `signal()` for component state.
+2. Use `toSignal()` when consuming observable streams.
+3. Use the `async` pipe for observable-based UI state.
+4. Use `ChangeDetectionStrategy.Eager` only as a temporary compatibility measure for legacy components.
+
+### 4.3 ABP List Pages (`ListService`)
+
+When working with `ListService`, prefer converting observable results to signals instead of manually subscribing.
+
+```typescript
+readonly data = toSignal(
+ this.list.hookToQuery(query => this.service.getList(query)),
+ { initialValue: { items: [], totalCount: 0 } },
+);
+```
+
+Update template bindings accordingly:
+
+- `data.items` → `data().items`
+- `data.totalCount` → `data().totalCount`
+
+### 4.4 Modals and Loading States
+
+For components such as `abp-modal`, `abp-button`, and permission or feature management dialogs, maintain state using signals.
+
+```typescript
+readonly isModalVisible = signal(false);
+readonly modalBusy = signal(false);
+```
+
+```html
+
+
+```
+
+If you use `*abpReplaceableTemplate`, pass signal values through `inputs.value` and update state through the corresponding event callbacks.
+
+### 4.5 Template Type Checking
+
+Angular 22 enables `strictTemplates` by default.
+
+Resolve template typing issues where possible, or temporarily disable strict template checking:
+
+```json
+{
+ "angularCompilerOptions": {
+ "strictTemplates": false
+ }
+}
+```
+
+Common adjustments include:
+
+- Updating optional chaining (`?.`) and null coalescing (`??`) usage
+- Guarding optional form references before binding
+- Resolving duplicate input or output bindings
+
+### 4.6 Upload Progress Events
+
+Applications that rely on upload progress events should include the XHR backend in browser-side HTTP configuration:
+
+```typescript
+provideHttpClient(withFetch(), withXhr());
+```
+
+Do not enable the XHR backend in server-side rendering (SSR) bootstrap code.
+
+### 4.7 Chart Components (`abp-chart`)
+
+If charts do not update after asynchronous data loading:
+
+- Store chart data in a signal
+- Bind chart inputs using signal values (for example, `[data]="chartData()"`)
+- Call `reinit()` after assigning new data rather than relying solely on `refresh()`
+
+## 5. Custom or Forked UI Modules
+
+If your project contains customized copies of ABP modules such as Identity, Tenant Management, Account, or CMS Kit:
+
+1. Compare your implementation with the updated package versions.
+2. Apply the recommended signal-based state management patterns.
+3. Re-test CRUD pages, permission dialogs, feature dialogs, and account-related workflows.
+
+## 6. Validation Checklist
+
+After completing the upgrade, verify that:
+
+- Dependencies are installed correctly without duplicate Angular versions
+- The application builds successfully
+- Unit tests pass (if applicable)
+- Login, registration, and password recovery workflows function correctly
+- CRUD list pages refresh as expected
+- Modal loading and busy states behave correctly
+- Permission and feature dialogs open and close correctly
+- Upload progress events work as expected (if applicable)
+- Dashboard charts render correctly after data is loaded
+
+## 7. Troubleshooting
+
+| Symptom | Likely Cause | Resolution |
+| ----------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------- |
+| Form type conflicts (`AbstractControl`, etc.) | Multiple Angular versions installed | Align package versions and perform a clean reinstall |
+| TypeScript deprecation errors related to `downlevelIteration` | TypeScript 6 diagnostics | Add `ignoreDeprecations: "6.0"` temporarily |
+| Errors involving optional configuration or environment properties | Stricter type checking | Add null checks and optional chaining where appropriate |
+| DTO or library compilation issues | Type incompatibilities in DTO definitions | Prefer interfaces and optional properties where appropriate |
+| Upload progress events are not emitted | Missing XHR backend configuration | Add `withXhr()` to browser-side HTTP configuration |
+| Charts remain empty after data loads | State changes are not being detected | Use signals and call `reinit()` after updating chart data |
+
+## 8. Summary
+
+When upgrading to Angular 22, focus on the following areas:
+
+1. Upgrade Angular, TypeScript, ABP, and commercial packages together.
+2. Update UI state management to use signals, `toSignal()`, or the `async` pipe where appropriate.
+3. Resolve TypeScript 6 and template type-checking issues.
+4. Validate critical application workflows, including modals, list pages, uploads, and chart components.
diff --git a/docs/en/release-info/migration-guides/abp-10-6.md b/docs/en/release-info/migration-guides/abp-10-6.md
new file mode 100644
index 0000000000..724e30ec2b
--- /dev/null
+++ b/docs/en/release-info/migration-guides/abp-10-6.md
@@ -0,0 +1,237 @@
+```json
+//[doc-seo]
+{
+ "Description": "Upgrade your ABP solutions from v10.5 to v10.6 with this migration guide covering important behavior and integration changes."
+}
+```
+
+# ABP Version 10.6 Migration Guide
+
+This document is a guide for upgrading ABP v10.5 solutions to ABP v10.6. There are some important changes that may require action in specific application scenarios.
+
+> **Package Version Changes:** Before upgrading, review the [Package Version Changes](../../package-version-changes.md) document to see version changes on dependent NuGet and NPM packages and align your project with ABP's internal package versions.
+
+## Open-Source (Framework)
+
+This version contains the following changes on the open-source side:
+
+### Background Jobs Infrastructure Extensions
+
+**Who is affected**
+
+- Applications using the default background job worker and wanting dedicated workers, parallel execution, or successful job retention.
+- Applications with a custom `IBackgroundJobStore` implementation.
+- Applications using the Background Jobs module with EF Core and enabling successful job retention.
+
+**What changed**
+
+- ABP adds opt-in support for:
+ - storing successfully completed jobs (`StoreSuccessfulJobs`)
+ - dedicated workers per job argument type (`AddDedicatedWorker(...)`)
+ - parallel job execution (`MaxParallelJobExecutionCount`)
+- `IBackgroundJobStore`, `IBackgroundJobWorker`, and related infrastructure gained new members.
+- EF Core stores add a `CompletionTime` column to background job records for retention scenarios.
+- All new runtime features are disabled by default.
+
+**What to do**
+
+No action is required if you do not enable the new options and do not maintain a custom background job store.
+
+If you maintain a custom `IBackgroundJobStore`, implement the new interface members so your solution compiles.
+
+If you enable `StoreSuccessfulJobs`, add/review the EF Core migration for the `CompletionTime` column and configure retention options explicitly:
+
+```csharp
+Configure(options =>
+{
+ options.StoreSuccessfulJobs = true;
+ options.SuccessfulJobRetentionTime = TimeSpan.FromDays(7);
+});
+```
+
+If you enable dedicated workers or parallel execution, configure the options consistently across all application instances and use a real distributed lock provider in clustered deployments.
+
+> See the [Background Jobs](../../framework/infrastructure/background-jobs/index.md) document and [#25742](https://github.com/abpframework/abp/pull/25742) for details.
+
+### API Definition and Proxy Generation for Uploads and Content Types
+
+**Who is affected**
+
+- Applications using generated Angular, jQuery, or C# proxies for upload endpoints.
+- Applications returning non-JSON response types from application services.
+- Applications that customized generated upload proxy signatures.
+
+**What changed**
+
+- API definition now exposes response `ContentTypes` and `IsRemoteStream`.
+- Generated Angular and jQuery proxies forward upload DTOs containing `IRemoteStreamContent` as multipart `FormData`.
+- Generated Angular upload method signatures may collapse the upload argument to `FormData`.
+- `RestService` now unwraps ABP error envelopes more consistently for text and blob response modes.
+
+**What to do**
+
+- Keep upload DTO types in `FormBodyBindingIgnoredTypes` as before.
+- Regenerate client proxies after upgrading.
+- Update custom client code that assumed upload proxies accepted the original DTO type instead of `FormData`.
+- Re-test file upload flows in Angular, MVC/jQuery, and C# client integrations.
+
+> See [#25639](https://github.com/abpframework/abp/pull/25639) for details.
+
+### Angular 22 Upgrade
+
+**Who is affected**
+
+- Applications using the ABP Angular UI.
+- Applications with custom Angular code, third-party Angular libraries, or CI pipelines pinned to Angular 21.
+
+**What changed**
+
+- ABP Angular packages and templates now target **Angular 22.0.x**.
+- Locale loading was improved with a fallback mechanism for missing or partial locale resources.
+
+**What to do**
+
+- Upgrade your Angular application dependencies together with ABP NPM packages.
+- Follow the official Angular update guidance for your current Angular version.
+- Re-run UI tests and rebuild custom Angular libraries after the upgrade.
+- Regenerate Angular proxies after upgrading backend packages.
+
+> See [#25690](https://github.com/abpframework/abp/pull/25690) and [#25734](https://github.com/abpframework/abp/pull/25734) for details.
+
+### Antiforgery User Id Claim Issuer Normalization
+
+**Who is affected**
+
+- Applications that serve a token-authenticated SPA and cookie-authenticated MVC/Razor Pages on the same origin.
+- Applications using Razor Pages modules such as Setting Management with antiforgery-protected POST handlers.
+
+**What changed**
+
+- ABP normalizes the user id claim issuer while generating and validating antiforgery tokens.
+- Razor Pages now use ABP's antiforgery validation path instead of only the built-in ASP.NET Core filter.
+- The behavior is enabled by default through `AbpAntiForgeryOptions.NormalizeUserIdClaimIssuer`.
+
+**What to do**
+
+- Re-test SPA + MVC mixed authentication flows, especially pages that POST immediately on load.
+- If you implemented custom antiforgery logic that depends on the raw claim issuer, review it after upgrading.
+- Disable the behavior only if you intentionally rely on the previous issuer-specific antiforgery identity:
+
+```csharp
+Configure(options =>
+{
+ options.NormalizeUserIdClaimIssuer = false;
+});
+```
+
+> See [#25655](https://github.com/abpframework/abp/pull/25655) and [#25669](https://github.com/abpframework/abp/pull/25669) for details.
+
+### OpenIddict Interactive Cookie `client_id` Fix
+
+**Who is affected**
+
+- Applications using OpenIddict authorization-code flows together with interactive cookie authentication.
+- Applications relying on audit logs or current-client resolution from cookie-authenticated requests.
+
+**What changed**
+
+- ABP removes `client_id` from the interactive authentication cookie when the cookie principal is refreshed.
+- Access tokens are unaffected.
+- Cookies that were already corrupted self-heal on the next refresh.
+
+**What to do**
+
+No action is required. Re-test authorization, account, and audit-log scenarios if you previously observed intermittent incorrect `ClientId` values in cookie-authenticated requests.
+
+> See [#25711](https://github.com/abpframework/abp/pull/25711) for details.
+
+### Access Token Forwarding for Authenticated Client Requests
+
+**Who is affected**
+
+- Applications using `HttpContextAbpAccessTokenProvider`.
+- Machine-to-machine integrations that authenticate with `client_credentials` and then call other protected APIs from the same request pipeline.
+
+**What changed**
+
+- The provider now forwards the incoming access token whenever the request is authenticated, not only when there is an interactive user.
+- `client_credentials` requests no longer fall back to configured identity clients in that scenario.
+
+**What to do**
+
+- Re-test service-to-service calls that rely on the current HTTP context access token.
+- Verify downstream API authorization when the caller authenticates as a client rather than a user.
+
+> See [#25740](https://github.com/abpframework/abp/pull/25740) for details.
+
+### Thread Current Principal Accessor Behavior
+
+**Who is affected**
+
+- Background jobs, hosted services, and other non-web code that reads `ICurrentPrincipalAccessor.Principal`.
+- Code that explicitly checks for `null` principals in non-web contexts.
+
+**What changed**
+
+- `ThreadCurrentPrincipalAccessor` now returns an anonymous `ClaimsPrincipal` instead of `null` when `Thread.CurrentPrincipal` is not set.
+
+**What to do**
+
+- Re-test background jobs and hosted services that branch on `Principal == null`.
+- Prefer checking authentication/identity state through claims or ABP's current user/client abstractions instead of relying on a `null` principal.
+
+> See [#25752](https://github.com/abpframework/abp/pull/25752) for details.
+
+### Dependency Updates
+
+**Who is affected**
+
+- Applications that pin ABP transitive dependencies directly.
+- Applications using `Microsoft.Data.SqlClient`, Swashbuckle, or Angular with fixed versions.
+
+**What changed**
+
+- `Microsoft.*` and `System.*` packages were upgraded to **10.0.9**.
+- `Microsoft.Data.SqlClient` was upgraded to **7.0.2**.
+- `Swashbuckle.AspNetCore` was upgraded to **10.2.3**.
+- ABP Angular packages were upgraded to **Angular 22.0.x**.
+
+**What to do**
+
+- Review your direct package references and align them with ABP's package versions where needed.
+- Rebuild and run database/integration tests if you directly use `Microsoft.Data.SqlClient`.
+- Re-test Swagger/OpenAPI integration if you customized Swashbuckle configuration.
+
+## Pro
+
+There are no explicitly marked breaking changes on the PRO side in this release scope. However, check the following if they apply to your application.
+
+### OpenIddict Generate Access Token UI
+
+**Who is affected**
+
+- Applications using OpenIddict application management UIs in ABP Commercial.
+
+**What changed**
+
+- Administrators can generate access tokens for OpenIddict applications from MVC, Blazor, MudBlazor, and Angular UIs.
+- The backend forwards `client_credentials` requests to `/connect/token`.
+
+**What to do**
+
+- Re-test OpenIddict application administration pages after upgrading.
+- Review who can access the new token-generation action in your authorization setup.
+
+### AI Management Indexing Resilience
+
+**Who is affected**
+
+- Applications using the AI Management module with document indexing enabled.
+
+**What changed**
+
+- Indexing is more resilient under memory pressure.
+
+**What to do**
+
+- Re-test document indexing on large datasets or memory-constrained environments after upgrading.
diff --git a/docs/en/release-info/migration-guides/index.md b/docs/en/release-info/migration-guides/index.md
index 418ec7d586..ec169d8242 100644
--- a/docs/en/release-info/migration-guides/index.md
+++ b/docs/en/release-info/migration-guides/index.md
@@ -9,6 +9,7 @@
The following documents explain how to migrate your existing ABP applications. We write migration documents only if you need to take an action while upgrading your solution. Otherwise, you can easily upgrade your solution using the [abp update command](../upgrading.md).
+- [10.5 to 10.6](abp-10-6.md)
- [10.4 to 10.5](abp-10-5.md)
- [10.3 to 10.4](abp-10-4.md)
- [10.x to 10.3](abp-10-3.md)
diff --git a/docs/en/release-info/release-notes.md b/docs/en/release-info/release-notes.md
index 9849eff38f..1dc34341f0 100644
--- a/docs/en/release-info/release-notes.md
+++ b/docs/en/release-info/release-notes.md
@@ -1,7 +1,7 @@
```json
//[doc-seo]
{
- "Description": "Explore the latest ABP Framework release notes, highlighting major features and enhancements for each version, including migration guidance."
+ "Description": "Explore the latest ABP Framework release notes, highlighting major features and enhancements for each version, including migration guidance."
}
```
@@ -14,9 +14,19 @@ Also see the following notes about ABP releases:
- [ABP Studio release notes](../studio/release-notes.md)
- [Change logs for ABP pro packages](https://abp.io/pro-releases)
+## 10.6 (2026-07-07)
+
+See the detailed **[blog post / announcement](https://abp.io/community/announcements/announcing-abp-10-6-release-candidate-reoq6kzw)** for the v10.6 release.
+
+- Background Jobs: Dedicated Workers, Parallel Execution, and Successful Job Retention
+- API Definition and Proxy Improvements for Content Types and Multipart Uploads
+- Angular UI: Angular has been upgraded to version 22. For a complete list of changes, including breaking changes, migration steps, and package updates, see the **[Angular Release Notes for v10.6](./../framework/ui/angular/release-notes/angular-22-typescript-6.md)**.
+- Antiforgery and OpenIddict Security Improvements
+- OpenIddict: Generate Access Token from the UI
+
## 10.5 (2026-06-30)
-See the detailed **[blog post / announcement](https://abp.io/community/articles/announcing-abp-10-5-release-candidate-k6oxdfle)** for the v10.5 release.
+See the detailed **[blog post / announcement](https://abp.io/community/announcements/announcing-abp-10-5-stable-release-2u589bsc)** for the v10.5 release.
- S3-Compatible Blob Storage Support
- OpenIddict: Default Scope Fallback Options
@@ -46,7 +56,7 @@ See the detailed **[blog post / announcement](https://abp.io/community/announcem
- Event Bus: String-Based Event Publishing with Dynamic Payload
- Background Jobs/Workers: String-Based Publishing with Dynamic Payload
- API Definition Endpoint: Descriptions and Documentation Support
-- Entity Cache: New Batch APIs (`FindMany`* / `GetMany*`)
+- Entity Cache: New Batch APIs (`FindMany`_ / `GetMany_`)
- Angular: User/Tenant Sharing and Tenant Switch Experience
- Angular: Upgrade to 21.2 + TypeScript 5.9
- Introducing the `Volo.Abp.LuckyPenny.AutoMapper` Provider
@@ -457,7 +467,7 @@ See the detailed **blog post / announcement** for the v2.8 release: [https://abp
## 2.7 (2020-05-07)
-See the detailed **blog post / announcement** for the v2.7 release: [https://abp.io/blog/ABP-Framework-v2_7_0-Has-Been-Released](https://abp.io/blog/ABP-Framework-v2_7_0-Has-Been-Released)
+See the detailed **blog post / announcement** for the v2.7 release: [https://abp.io/blog/ABP-Framework-v2_7_0-Has-Been-Released](https://abp.io/blog/ABP-Framework-v2_7_0-Has-Been-Released)
- New module: **Text template management** (with angular and mvc UI - document is [coming](../modules/text-template-management.md)).
- **Dynamically add properties** to current entities of the depended modules (see [module entity extensions](../framework/architecture/modularity/extending/module-entity-extensions.md))
@@ -468,9 +478,8 @@ See the detailed **blog post / announcement** for the v2.7 release: [https://ab
- **Optimize database migrations** & seed code for multi-tenant multi-database systems.
- ABP Suite: Make **menu item active** on navigation menu when selected.
- ABP Suite: Improve **enum usage** while creating new entities.
-- Bug fixes in the [Lepton Theme](https://abp.io/themes), [ABP Suite](https://abp.io/tools/suite) and other modules.
+- Bug fixes in the [Lepton Theme](https://abp.io/themes), [ABP Suite](https://abp.io/tools/suite) and other modules.
## See Also
- [Road map](road-map.md)
-
diff --git a/docs/en/release-info/road-map.md b/docs/en/release-info/road-map.md
index a0b3f856e7..6df6ca81fa 100644
--- a/docs/en/release-info/road-map.md
+++ b/docs/en/release-info/road-map.md
@@ -1,7 +1,7 @@
```json
//[doc-seo]
{
- "Description": "Explore the ABP Platform Road Map for insights on upcoming features, release schedules, and improvements in version 10.5, planned for June 2026."
+ "Description": "Explore the ABP Platform Road Map for insights on upcoming features, release schedules, and improvements in version 10.7, planned for August 2026."
}
```
@@ -11,28 +11,29 @@ This document provides a road map, release schedule, and planned features for th
## Next Versions
-### v10.6
+### v10.7
-The next planned version will be 10.6, which is scheduled to be released as a stable version in July 2026. We will be mostly working on the following topics:
+The next planned version will be 10.7, which is scheduled to be released as a stable version in August 2026. Based on the currently open issues and pull requests across the ABP ecosystem, we will be mostly working on the following topics:
* Framework
- * Token verification improvements with refresh token support and distributed locking
- * Angular UI fixes and proxy generation improvements
- * Better handling for extra properties, object mapping and auditing edge cases
- * Hybrid UI / page embedding infrastructure
- * Upgrading 3rd-party dependencies and evaluating replacements where needed
- * General bug fixing and improvements in core framework packages
+ * Cookie authentication: refresh token support and distributed locking ([#25011](https://github.com/abpframework/abp/issues/25011))
+ * jQuery 4.x upgrade, or removing jQuery as a dependency ([#25123](https://github.com/abpframework/abp/issues/25123))
+ * AI agent skills distributed as versioned plugins ([#25712](https://github.com/abpframework/abp/issues/25712))
+ * Hybrid UI / page embedding infrastructure ([#23102](https://github.com/abpframework/abp/issues/23102), [#23161](https://github.com/abpframework/abp/issues/23161))
+ * Microsoft Agent Framework migration and native agent skills support ([#24310](https://github.com/abpframework/abp/issues/24310), [#25194](https://github.com/abpframework/abp/issues/25194))
+ * Better ExtraProperties mapping for EF Core ([#23546](https://github.com/abpframework/abp/issues/23546))
+ * Upgrading 3rd-party dependencies and general bug fixing in core framework packages
* ABP Suite
- * Improvements on generated codes for nullability
- * Improvements on master-detail page design (making it more compact)
+ * Replace the templating system with Scriban while preserving backward compatibility
+ * Support for additional property types like `DateTimeOffset`, `TimeSpan` and numeric enums
+ * Display names and ordering for properties and navigation properties
+ * Filter on inherited properties and namespace-based UI foldering
* Low-Code system integration
- * Better support for additional property types like `DateTimeOffset`, `TimeSpan` and numeric enum scenarios
- * Improvements for generated file upload, navigation property and display-name experiences
+ * Improvements on generated code nullability, master-detail pages and file upload experiences
* ABP Studio
- * AI Coding Agent and MCP integration
- * Modern solution wizard improvements and low-code support
+ * Low-Code platform integration
* Theme Builder: live preview, project integration and import/export
* Linux support and packaging improvements
* Better React / React Native / Thin UI template experience
@@ -41,11 +42,12 @@ The next planned version will be 10.6, which is scheduled to be released as a st
* Terminal, browser and built-in developer productivity enhancements
* Application Modules
- * AI Management: chat history, multi-tenancy and tenant-scoped workspace capabilities
+ * AI Management: chat history
+ * AI Management: multi-tenancy and tenant-scoped workspace capabilities
+ * RAG: Cloudflare `/crawl` endpoint as a data source
* New module: Chat with your data
- * Low-Code designer and low-code platform integrations
- * CMS Kit and public website improvements
* Payment module e-mail notification improvements
+ * CMS Kit and public website improvements
* UI/UX improvements on existing application modules
* Updating existing tutorials & documents (with other UI & DB options)
@@ -61,42 +63,46 @@ The *Next Versions* section above shows the main focus of the planned versions.
The ABP framework is [open source](https://github.com/abpframework/abp) and free for everyone. You can see its [public backlog](https://github.com/abpframework/abp/milestone/2). Here are some of the selected backlog items and longer-term topics:
* [#23102](https://github.com/abpframework/abp/issues/23102) / ABP Hybrid UI System: Re-using module UIs in different technologies
+* [#23161](https://github.com/abpframework/abp/issues/23161) / Page Embedding Feature (aka Hybrid UI)
* [#25123](https://github.com/abpframework/abp/issues/25123) / Upgrade jQuery to 4.x, or consider removing it as dependency
-* [#24742](https://github.com/abpframework/abp/issues/24742) / Add Support for LiteDB as a Database Provider
-* [#24442](https://github.com/abpframework/abp/issues/24442) / Add Couchbase EF Core Provider Integration
+* [#17093](https://github.com/abpframework/abp/issues/17093) / MVC UI: decouple jQuery
* [#24310](https://github.com/abpframework/abp/issues/24310) / Migrate Volo.Abp.AI Semantic Kernel to Microsoft Agent Framework
+* [#25194](https://github.com/abpframework/abp/issues/25194) / Integrate Microsoft.Agents.AI for native Agent Skills support
* [#23575](https://github.com/abpframework/abp/issues/23575) / Support list/enumerable of complex types for ABP dynamic/static C# proxies on GET requests
* [#23546](https://github.com/abpframework/abp/issues/23546) / Better ExtraProperties mapping for EF Core
* [#23935](https://github.com/abpframework/abp/issues/23935) / Hybrid Cache Support for EntityCache
* [#22931](https://github.com/abpframework/abp/issues/22931) / Angular - Support dynamic URLs for breadcrumbs
-* [#25032](https://github.com/abpframework/abp/issues/25032) / Guidance and infrastructure considerations for gRPC-based scenarios
-* [#2882](https://github.com/abpframework/abp/issues/2882) / Providing a gRPC integration infrastructure
-* [#57](https://github.com/abpframework/abp/issues/57) / Built-in CQRS infrastructure
+* [#24742](https://github.com/abpframework/abp/issues/24742) / Add Support for LiteDB as a Database Provider
+* [#24442](https://github.com/abpframework/abp/issues/24442) / Add Couchbase EF Core Provider Integration
+* [#2882](https://github.com/abpframework/abp/issues/2882) / ABP gRPC Integration
+* [#57](https://github.com/abpframework/abp/issues/57) / CQRS infrastructure
* [#58](https://github.com/abpframework/abp/issues/58) / Content localization system (multilingual entities)
-* [#4223](https://github.com/abpframework/abp/issues/4223) / WebHook system
-* [#162](https://github.com/abpframework/abp/issues/162) / Azure ElasticDB integration for multitenancy
-* [#2296](https://github.com/abpframework/abp/issues/2296) / Feature toggling infrastructure
+* [#4223](https://github.com/abpframework/abp/issues/4223) / WebHook System
+* [#162](https://github.com/abpframework/abp/issues/162) / Azure ElasticDB Integration for multitenancy
+* [#2296](https://github.com/abpframework/abp/issues/2296) / Implementing Feature Toggle
* [#15932](https://github.com/abpframework/abp/issues/15932) / Introduce ABP Diagnostics Module
* [#16744](https://github.com/abpframework/abp/issues/16744) / State Management API
-* [#119](https://github.com/abpframework/abp/issues/119) / REST API versioning improvements
-* [#2087](https://github.com/abpframework/abp/issues/2087) / RavenDB database support
+* [#119](https://github.com/abpframework/abp/issues/119) / REST API Versioning Improvements
+* [#2087](https://github.com/abpframework/abp/issues/2087) / Add RavenDB Database support
### Application Modules / UI Themes
ABP Platform provides many (free and commercial) [pre-built application modules](../modules/index.md) and modern [UI themes](../ui-themes/index.md). In every release, many enhancements and bugfixes are delivered for these modules and themes. Important backlog topics currently include:
* AI Management module: chat history, multi-tenancy, tenant workspaces and operational hardening
-* CMS Kit module: media gallery and richer public website capabilities
+* New module: Chat with your data
+* RAG with external data sources such as website crawling
* Payment module: richer notifications and invoice-oriented scenarios
+* CMS Kit: Meta information for SEO
* Audit logging UI: filter redesign and UX improvements
* Identity Pro: richer filtering and organization-unit UX improvements
* LeptonX and existing UIs: new layouts, styles and usability refinements
-* New module ideas: Chat with your data, AI Search, user notification and dynamic dashboard
### ABP Studio
[ABP Studio](../studio/index.md) is a cross-platform desktop application for ABP and .NET developers to simplify and automate daily tasks of developers. It has a community (free) edition as well as commercial capabilities. Here are some of the important planned features and active backlog topics for the next ABP Studio versions:
+* Low-Code: ABP Studio Integration
* Theme builder for LeptonX, including live preview, management UI and project integration
* Analyze user solutions to explore entities, domain services, application services, pages and other fundamental objects
* AI agent/browser capabilities and developer-assistant experiences
@@ -107,16 +113,14 @@ ABP Platform provides many (free and commercial) [pre-built application modules]
* More options while creating new solutions, modules and services
* Better environment-variable, deployment and Kubernetes experiences
* Compare changes on startup templates when a new ABP version is published
-* Rapid application development and low-code oriented features
* ABP support integration and better diagnostics/error experiences
### ABP Suite
[ABP Suite](../suite/index.md) is a GUI application that is mainly used to generate CRUD-style pages in your application. You define your entity and it can generate all the code from the database to the UI. Here are some of the important planned features for the next ABP Suite versions:
+* Replace the current templating system with Scriban while preserving backward compatibility
* Better nullability support in generated code
-* MudBlazor support
-* Replacing the current templating system with a text engine while preserving backward compatibility
* Support for additional property types like `DateTimeOffset` and `TimeSpan`
* Handle image properties for entities (in addition to file properties, which are already supported)
* Allow to define display names and better ordering for properties and navigation properties
diff --git a/docs/en/samples/index.md b/docs/en/samples/index.md
index 9bce96be17..7b0642d7f4 100644
--- a/docs/en/samples/index.md
+++ b/docs/en/samples/index.md
@@ -1,7 +1,7 @@
```json
//[doc-seo]
{
- "Description": "Explore a variety of ABP Framework samples, complete with live demos, source code, and tutorials to enhance your development skills!"
+ "Description": "Explore a variety of ABP Framework samples, complete with live demos, source code, and tutorials to enhance your development skills!"
}
```
@@ -13,8 +13,8 @@ This document provides a list of samples built with ABP. Each sample is briefly
A reference application built with ABP. It implements the Domain Driven Design with multiple application layers.
-* [Live demo](https://www.openeventhub.com/)
-* [Source code](https://github.com/abpframework/eventhub)
+- [Live demo](https://www.openeventhub.com/)
+- [Source code](https://github.com/abpframework/eventhub)

@@ -25,7 +25,7 @@ A reference application built with ABP. It implements the Domain Driven Design w
Reference microservice solution built with ABP and .NET.
-* [Source code](https://github.com/abpframework/eShopOnAbp)
+- [Source code](https://github.com/abpframework/eShopOnAbp)

@@ -33,8 +33,8 @@ Reference microservice solution built with ABP and .NET.
A minimal example website built with the [CMS Kit module](../modules/cms-kit/index.md).
-* [Live demo](https://cms-kit-demo.abpdemo.com/)
-* [Source code](https://github.com/abpframework/cms-kit-demo)
+- [Live demo](https://cms-kit-demo.abpdemo.com/)
+- [Source code](https://github.com/abpframework/cms-kit-demo)

@@ -42,8 +42,8 @@ A minimal example website built with the [CMS Kit module](../modules/cms-kit/ind
A middle-size CRM application built with ABP.
-* [Live demo](http://easycrm.abp.io/)
-* [Click here](easy-crm.md) to see the details and download the source code.
+- [Live demo](http://easycrm.abp.io/)
+- [Click here](easy-crm.md) to see the details and download the source code.

@@ -51,20 +51,20 @@ A middle-size CRM application built with ABP.
A simple CRUD application to show basic principles of developing an application with ABP. The same sample was implemented with different technologies and different modules:
-* **Book Store: Razor Pages UI & Entity Framework Core**
- * [Tutorial](../tutorials/book-store/part-01.md?UI=MVC&DB=EF)
- * [Source code](https://github.com/abpframework/abp-samples/tree/master/BookStore-Mvc-EfCore)
- * [Download source code (with PRO modules) *](https://abp.io/Account/Login?returnUrl=/api/download/samples/bookstore-mvc-ef)
-* **Book Store: Blazor UI & Entity Framework Core**
- * [Tutorial](../tutorials/book-store/part-01.md?UI=Blazor&DB=EF)
- * [Source code](https://github.com/abpframework/abp-samples/tree/master/BookStore-Blazor-EfCore)
- * [Download source code (with PRO modules) *](https://abp.io/Account/Login?returnUrl=/api/download/samples/bookstore-blazor-efcore)
-* **Book Store: Angular UI & MongoDB**
- * [Tutorial](../tutorials/book-store/part-01.md?UI=NG&DB=Mongo)
- * [Source code](https://github.com/abpframework/abp-samples/tree/master/BookStore-Angular-MongoDb)
- * [Download source code (with PRO modules) *](https://abp.io/Account/Login?returnUrl=/api/download/samples/bookstore-angular-mongodb)
-* **Book Store: Modular application (Razor Pages UI & EF Core)**
- * [Source code](https://github.com/abpframework/abp-samples/tree/master/BookStore-Modular)
+- **Book Store: Razor Pages UI & Entity Framework Core**
+ - [Tutorial](../tutorials/book-store/part-01.md?UI=MVC&DB=EF)
+ - [Source code](https://github.com/abpframework/abp-samples/tree/master/BookStore-Mvc-EfCore)
+ - [Download source code (with PRO modules) \*](https://abp.io/Account/Login?returnUrl=/api/download/samples/bookstore-mvc-ef)
+- **Book Store: Blazor UI & Entity Framework Core**
+ - [Tutorial](../tutorials/book-store/part-01.md?UI=Blazor&DB=EF)
+ - [Source code](https://github.com/abpframework/abp-samples/tree/master/BookStore-Blazor-EfCore)
+ - [Download source code (with PRO modules) \*](https://abp.io/Account/Login?returnUrl=/api/download/samples/bookstore-blazor-efcore)
+- **Book Store: Angular UI & MongoDB**
+ - [Tutorial](../tutorials/book-store/part-01.md?UI=NG&DB=Mongo)
+ - [Source code](https://github.com/abpframework/abp-samples/tree/master/BookStore-Angular-MongoDb)
+ - [Download source code (with PRO modules) \*](https://abp.io/Account/Login?returnUrl=/api/download/samples/bookstore-angular-mongodb)
+- **Book Store: Modular application (Razor Pages UI & EF Core)**
+ - [Source code](https://github.com/abpframework/abp-samples/tree/master/BookStore-Modular)
If you want to create the BookStore application and generate CRUD pages automatically with ABP Suite, please refer to the [Book Store Application (with ABP Suite) tutorial](../tutorials/book-store-with-abp-suite/part-01.md). Also, you can follow the [Mobile Application Development Tutorials](../tutorials/mobile/index.md), if you want to implement the CRUD operations for [MAUI](../tutorials/mobile/maui/index.md) & [React Native](../tutorials/mobile/react-native/index.md) mobile applications.
@@ -74,9 +74,9 @@ If you want to create the BookStore application and generate CRUD pages automati
A modular monolith application that demonstrates how to create, compose, and communicate between application modules to build a modular web application:
-* **ModularCRM: Razor Pages UI & Entity Framework Core**
- * [Tutorial](../tutorials/modular-crm/part-01.md?UI=MVC&DB=EF)
- * [Source code](https://github.com/abpframework/abp-samples/tree/master/ModularCRM)
+- **ModularCRM: Razor Pages UI & Entity Framework Core**
+ - [Tutorial](../tutorials/modular-crm/part-01.md?UI=MVC&DB=EF)
+ - [Source code](https://github.com/abpframework/abp-samples/tree/master/ModularCRM)
## CloudCrm
@@ -84,13 +84,27 @@ A modular monolith application that demonstrates how to create, compose, and com
A microservice solution that shows how to start a new microservice solution, create services and communicate between these services. It's a reference tutorial to learn to use these services from a web application through an API gateway and automatically generate CRUD pages using the ABP Suite tool:
-* **CloudCRM: Razor Pages UI & Entity Framework Core**
- * [Tutorial](../tutorials/microservice/part-01.md?UI=MVC&DB=EF)
- * [Download source code](https://abp.io/api/download/samples/cloud-crm-mvc-ef)
+- **CloudCRM: Razor Pages UI & Entity Framework Core**
+ - [Tutorial](../tutorials/microservice/part-01.md?UI=MVC&DB=EF)
+ - [Download source code](https://abp.io/api/download/samples/cloud-crm-mvc-ef)
+
+## Hanova & Habitly
+
+> This sample application is available exclusively to users with an [ABP Business license or higher](https://abp.io/pricing).
+
+Hanova and Habitly are production-ready sample applications generated from the ABP Studio templates using React and React Native. Their features were built using the [ABP Studio AI Agent](https://abp.io/docs/latest/studio/ai-agent), demonstrating how AI can accelerate the development of real-world applications.
+
+- **[Hanova Source Code](https://abp.io/api/download/samples/Hanova)**
+- **[Habitly Source Code](https://abp.io/api/download/samples/reactnative-efcore-psql-habitly)**
+
+To learn more, see:
+
+- [How the ABP AI Agent helps build production-ready applications](https://abp.io/community/articles/template-in-product-out-building-hanova-with-the-abp-ai-hcntpk3j#gsc.tab=0)
+- [How we modernized React Native application development](https://abp.io/community/articles/new-abp-modern-react-native-template-rxjiyrpb#gsc.tab=0)
## Other Samples
ABP Platform provides many sample applications demonstrating various use cases and integrations. You can:
-* Browse all sample applications in the [abp-samples repository](https://github.com/abpframework/abp-samples).
-* Read detailed articles and tutorials in the [ABP Community](https://abp.io/community), which are shared by ABP Community & Contributors.
\ No newline at end of file
+- Browse all sample applications in the [abp-samples repository](https://github.com/abpframework/abp-samples).
+- Read detailed articles and tutorials in the [ABP Community](https://abp.io/community), which are shared by ABP Community & Contributors.
diff --git a/docs/en/studio/overview.md b/docs/en/studio/overview.md
index 13e539ebcf..bf30d40694 100644
--- a/docs/en/studio/overview.md
+++ b/docs/en/studio/overview.md
@@ -112,6 +112,8 @@ Key features of the AI Agent include:
- **Attachments**: Attach supported files or images to provide task-specific context.
- **Studio Integration**: Use enabled Studio tools for build, monitoring, applications, containers, tasks, proxies, migrations, and Git-related workflows.
+For a production example of this workflow in action, see the [Hanova & Habitly samples](../samples/index.md#hanova--habitly).
+
> **Note**: Review the Privacy Notice available in the AI Agent panel to understand how your data is handled.
For the technical AI Agent reference, see [ABP Studio: AI Agent](./ai-agent.md).
diff --git a/docs/en/tutorials/mobile/index.md b/docs/en/tutorials/mobile/index.md
index 20767b9f1d..fe5635fcd2 100644
--- a/docs/en/tutorials/mobile/index.md
+++ b/docs/en/tutorials/mobile/index.md
@@ -11,6 +11,8 @@
Mobile application development tutorials are designed for developers who have completed [the web development part of the tutorial](../book-store/index.md) and wish to continue building the mobile version of the application.
+The React Native track follows the modernized ABP mobile template, and the [Habitly sample](../../samples/index.md#hanova--habitly) shows that stack in a production-ready app.
+
## Tutorials
You can choose between two mobile applications: [**.NET MAUI**](../../framework/ui/maui/index.md) or [**React Native**](../../framework/ui/react-native/index.md). Choose your framework and continue building your mobile application!
@@ -30,4 +32,4 @@ The .NET MAUI tutorial walks you through creating a mobile version of your books
The React Native tutorial provides instructions for building the bookstore app using JavaScript.
* [React Native - Mobile Application Tutorial](./react-native/index.md)
-* [Source Code](https://abp.io/Account/Login?returnUrl=/api/download/samples/bookstore-react-native-mongodb)
\ No newline at end of file
+* [Source Code](https://abp.io/Account/Login?returnUrl=/api/download/samples/bookstore-react-native-mongodb)
diff --git a/docs/en/tutorials/mobile/react-native/index.md b/docs/en/tutorials/mobile/react-native/index.md
index db41615d98..af2a14d028 100644
--- a/docs/en/tutorials/mobile/react-native/index.md
+++ b/docs/en/tutorials/mobile/react-native/index.md
@@ -18,6 +18,8 @@ The React Native mobile option is _available for_ **_Team_** _or higher licenses
- The mobile template was modernized in 2026: it now uses **NativeWind v4** (Tailwind CSS for React Native) for styling, **Bottom Tab navigation** by default, and the **Redux Toolkit** store with hook-based access (`useSelector` / `useDispatch`). The `connectToRedux` HOC, the `DrawerNavigator`, and the legacy `DataList`/`AbpSelect` components from earlier versions no longer ship with the template — this tutorial walks through building the new equivalents.
- Before starting, please make sure that the [React Native Development Environment](../../../framework/ui/react-native/index.md) is ready on your machine.
+If you'd like to inspect a complete implementation first, [Habitly](../../../samples/index.md#hanova--habitly) is the production-ready mobile sample that follows this modern template direction.
+
## Running the Application
Before implementing UI changes, run the `Acme.BookStore` mobile application and verify that login works:
diff --git a/framework/src/Volo.Abp.AspNetCore.Components.Web/Volo/Abp/AspNetCore/Components/Web/Security/AbpComponentsClaimsCache.cs b/framework/src/Volo.Abp.AspNetCore.Components.Web/Volo/Abp/AspNetCore/Components/Web/Security/AbpComponentsClaimsCache.cs
index c98edaef19..e5356da660 100644
--- a/framework/src/Volo.Abp.AspNetCore.Components.Web/Volo/Abp/AspNetCore/Components/Web/Security/AbpComponentsClaimsCache.cs
+++ b/framework/src/Volo.Abp.AspNetCore.Components.Web/Volo/Abp/AspNetCore/Components/Web/Security/AbpComponentsClaimsCache.cs
@@ -8,7 +8,7 @@ namespace Volo.Abp.AspNetCore.Components.Web.Security;
public class AbpComponentsClaimsCache : IScopedDependency
{
- public ClaimsPrincipal Principal { get; private set; } = default!;
+ public ClaimsPrincipal Principal { get; private set; } = new ClaimsPrincipal(new ClaimsIdentity());
private readonly AuthenticationStateProvider? _authenticationStateProvider;
diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowActionFilter.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowActionFilter.cs
index 5cd9bf8089..f1997fb8d4 100644
--- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowActionFilter.cs
+++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowActionFilter.cs
@@ -1,5 +1,4 @@
using System;
-using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Abstractions;
@@ -7,6 +6,7 @@ using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using Volo.Abp.AspNetCore.Filters;
using Volo.Abp.DependencyInjection;
+using Volo.Abp.Http;
using Volo.Abp.Threading;
using Volo.Abp.Uow;
@@ -81,7 +81,8 @@ public class AbpUowActionFilter : IAsyncActionFilter, IAbpFilter, ITransientDepe
{
var abpUnitOfWorkDefaultOptions = context.GetRequiredService>().Value;
options.IsTransactional = abpUnitOfWorkDefaultOptions.CalculateIsTransactional(
- autoValue: !string.Equals(context.HttpContext.Request.Method, HttpMethod.Get.Method, StringComparison.OrdinalIgnoreCase)
+ autoValue: !(HttpMethodHelper.IsGet(context.HttpContext.Request.Method)
+ || HttpMethodHelper.IsQuery(context.HttpContext.Request.Method))
);
}
diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowPageFilter.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowPageFilter.cs
index b086df1a42..d8f86bf261 100644
--- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowPageFilter.cs
+++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowPageFilter.cs
@@ -1,5 +1,4 @@
using System;
-using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Abstractions;
@@ -7,6 +6,7 @@ using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using Volo.Abp.AspNetCore.Filters;
using Volo.Abp.DependencyInjection;
+using Volo.Abp.Http;
using Volo.Abp.Threading;
using Volo.Abp.Uow;
@@ -86,7 +86,8 @@ public class AbpUowPageFilter : IAsyncPageFilter, IAbpFilter, ITransientDependen
{
var abpUnitOfWorkDefaultOptions = context.GetRequiredService>().Value;
options.IsTransactional = abpUnitOfWorkDefaultOptions.CalculateIsTransactional(
- autoValue: !string.Equals(context.HttpContext.Request.Method, HttpMethod.Get.Method, StringComparison.OrdinalIgnoreCase)
+ autoValue: !(HttpMethodHelper.IsGet(context.HttpContext.Request.Method)
+ || HttpMethodHelper.IsQuery(context.HttpContext.Request.Method))
);
}
diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs
index 86cc997ae7..112b3be61a 100644
--- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs
+++ b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Options;
using Volo.Abp.AspNetCore.Middleware;
using Volo.Abp.Auditing;
using Volo.Abp.DependencyInjection;
+using Volo.Abp.Http;
using Volo.Abp.Uow;
using Volo.Abp.Users;
@@ -135,8 +136,9 @@ public class AbpAuditingMiddleware : AbpMiddlewareBase, ITransientDependency
}
if (!AuditingOptions.IsEnabledForGetRequests &&
- (string.Equals(httpContext.Request.Method, HttpMethods.Get, StringComparison.OrdinalIgnoreCase) ||
- string.Equals(httpContext.Request.Method, HttpMethods.Head, StringComparison.OrdinalIgnoreCase)))
+ (HttpMethodHelper.IsGet(httpContext.Request.Method) ||
+ HttpMethodHelper.IsHead(httpContext.Request.Method) ||
+ HttpMethodHelper.IsQuery(httpContext.Request.Method)))
{
return false;
}
diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider.cs
index a1758d3a90..e323b323d1 100644
--- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider.cs
+++ b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider.cs
@@ -1,8 +1,8 @@
using System;
-using System.Net.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection;
+using Volo.Abp.Http;
using Volo.Abp.Uow;
namespace Volo.Abp.AspNetCore.Uow;
@@ -37,10 +37,8 @@ public class AspNetCoreUnitOfWorkTransactionBehaviourProvider : IUnitOfWorkTrans
}
}
- return !string.Equals(
- httpContext.Request.Method,
- HttpMethod.Get.Method, StringComparison.OrdinalIgnoreCase
- );
+ var method = httpContext.Request.Method;
+ return !(HttpMethodHelper.IsGet(method) || HttpMethodHelper.IsQuery(method));
}
}
diff --git a/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AbpAuditingOptions.cs b/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AbpAuditingOptions.cs
index f9f5284b29..81d87023f7 100644
--- a/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AbpAuditingOptions.cs
+++ b/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AbpAuditingOptions.cs
@@ -62,6 +62,7 @@ public class AbpAuditingOptions
//TODO: Move this to asp.net core layer or convert it to a more dynamic strategy?
///
/// Default: false.
+ /// When false, safe methods (GET, HEAD and QUERY) are excluded from audit logging.
///
public bool IsEnabledForGetRequests { get; set; }
diff --git a/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditingInterceptor.cs b/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditingInterceptor.cs
index 52c6302da2..0dd70d68e7 100644
--- a/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditingInterceptor.cs
+++ b/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditingInterceptor.cs
@@ -193,6 +193,7 @@ public class AuditingInterceptor : AbpInterceptor, ITransientDependency
if (!options.IsEnabledForGetRequests &&
(string.Equals(auditLogInfo.HttpMethod, "Get", StringComparison.OrdinalIgnoreCase) ||
string.Equals(auditLogInfo.HttpMethod, "Head", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(auditLogInfo.HttpMethod, "Query", StringComparison.OrdinalIgnoreCase) ||
invocation.Method.Name.StartsWith("Get", StringComparison.OrdinalIgnoreCase)))
{
return false;
diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerOptions.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerOptions.cs
index b339508028..f0dc0aa500 100644
--- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerOptions.cs
+++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerOptions.cs
@@ -1,4 +1,8 @@
-namespace Volo.Abp.BackgroundJobs;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Volo.Abp.BackgroundJobs;
public class AbpBackgroundJobWorkerOptions
{
@@ -15,6 +19,7 @@ public class AbpBackgroundJobWorkerOptions
///
/// Maximum count of jobs to fetch from data store in one loop.
+ /// Also used as the batch size for the retention cleanup deletions (see ).
/// Default: 1000.
///
public int MaxJobFetchCount { get; set; }
@@ -42,7 +47,61 @@ public class AbpBackgroundJobWorkerOptions
/// Distributed lock name for the worker.
/// Default value: "AbpBackgroundJobWorker".
///
- public string DistributedLockName { get; set; }
+ public string DistributedLockName { get; set; }
+
+ ///
+ /// When set to true, a successfully completed job is kept (its is set)
+ /// instead of being deleted, so it can be used for auditing/history. Completed jobs are excluded from the
+ /// waiting jobs query and are removed by the retention cleanup (see ).
+ /// Default value: false (successful jobs are deleted).
+ ///
+ public bool StoreSuccessfulJobs { get; set; }
+
+ ///
+ /// How long a kept (successfully completed) job is retained before the cleanup deletes it.
+ /// Only relevant when is true. Set to null to keep them forever (no automatic cleanup).
+ /// Default value: 7 days.
+ ///
+ public TimeSpan? SuccessfulJobRetentionTime { get; set; }
+
+ ///
+ /// Interval (as milliseconds) between cleanup runs that delete retained successful jobs older than .
+ /// Default value: 3,600,000 (1 hour).
+ ///
+ public int CleanSuccessfulJobsPeriod { get; set; }
+
+ ///
+ /// Distributed lock name for the cleanup worker.
+ /// Default value: "AbpBackgroundJobCleanup".
+ ///
+ public string CleanupDistributedLockName { get; set; }
+
+ ///
+ /// Dedicated workers, each processing only the configured job types with its own distributed lock.
+ /// When this list is not empty, an additional default worker is started that processes all
+ /// other job types. When it is empty, a single default worker processes all job types (the default behavior).
+ /// Use to add a dedicated worker.
+ ///
+ public List WorkerConfigurations { get; }
+
+ ///
+ /// Maximum number of jobs that a single worker executes in parallel within one poll cycle.
+ /// When it is 1 (default), jobs are executed sequentially under a single worker distributed lock
+ /// (the default behavior). When greater than 1, the worker distributed lock is not used; instead
+ /// each job is claimed with its own distributed lock so multiple application instances can execute
+ /// different jobs concurrently.
+ /// This value should be configured consistently across all application instances: mixing sequential
+ /// (worker lock) and parallel (per-job lock) instances removes the common mutual exclusion and may
+ /// let the same job run on more than one instance.
+ /// Default value: 1.
+ ///
+ public int MaxParallelJobExecutionCount { get; set; }
+
+ ///
+ /// Prefix of the per-job distributed lock name used when is greater than 1.
+ /// Default value: "AbpBackgroundJob:".
+ ///
+ public string PerJobDistributedLockPrefix { get; set; }
public AbpBackgroundJobWorkerOptions()
{
@@ -52,5 +111,97 @@ public class AbpBackgroundJobWorkerOptions
DefaultTimeout = 172800;
DefaultWaitFactor = 2.0;
DistributedLockName = "AbpBackgroundJobWorker";
+ WorkerConfigurations = new List();
+ MaxParallelJobExecutionCount = 1;
+ PerJobDistributedLockPrefix = "AbpBackgroundJob:";
+ SuccessfulJobRetentionTime = TimeSpan.FromDays(7);
+ CleanSuccessfulJobsPeriod = 3600000;
+ CleanupDistributedLockName = "AbpBackgroundJobCleanup";
+ }
+
+ ///
+ /// Adds a dedicated worker that processes only the given job argument types.
+ ///
+ /// A unique distributed lock name for this worker.
+ /// The job argument types handled exclusively by this worker.
+ public AbpBackgroundJobWorkerOptions AddDedicatedWorker(string lockName, params Type[] jobArgsTypes)
+ {
+ Check.NotNullOrEmpty(jobArgsTypes, nameof(jobArgsTypes));
+
+ var configuration = new BackgroundJobWorkerConfiguration(lockName, jobArgsTypes.Distinct().ToArray());
+
+ var duplicateType = configuration.JobArgsTypes.FirstOrDefault(
+ type => WorkerConfigurations.Any(c => c.JobArgsTypes.Contains(type)));
+ if (duplicateType != null)
+ {
+ throw new AbpException(
+ $"The background job args type '{duplicateType.FullName}' is already assigned to a dedicated worker. " +
+ $"Each job type can be handled by only one dedicated worker.");
+ }
+
+ if (lockName == DistributedLockName || WorkerConfigurations.Any(c => c.LockName == lockName))
+ {
+ throw new AbpException(
+ $"The distributed lock name '{lockName}' is already used by another background job worker. " +
+ $"Each worker must have a unique lock name.");
+ }
+
+ WorkerConfigurations.Add(configuration);
+ return this;
+ }
+
+ public AbpBackgroundJobWorkerOptions AddDedicatedWorker(string lockName)
+ {
+ return AddDedicatedWorker(lockName, typeof(TArgs));
+ }
+
+ public AbpBackgroundJobWorkerOptions AddDedicatedWorker(string lockName)
+ {
+ return AddDedicatedWorker(lockName, typeof(TArgs1), typeof(TArgs2));
+ }
+
+ public AbpBackgroundJobWorkerOptions AddDedicatedWorker(string lockName)
+ {
+ return AddDedicatedWorker(lockName, typeof(TArgs1), typeof(TArgs2), typeof(TArgs3));
+ }
+
+ ///
+ /// Adds a dedicated worker with a lock name derived from the given job argument type names.
+ /// Use the AddDedicatedWorker(string lockName, ...) overloads to set an explicit lock name.
+ ///
+ /// The job argument types handled exclusively by this worker.
+ public AbpBackgroundJobWorkerOptions AddDedicatedWorker(params Type[] jobArgsTypes)
+ {
+ return AddDedicatedWorker(GetDedicatedWorkerLockName(jobArgsTypes), jobArgsTypes);
+ }
+
+ public AbpBackgroundJobWorkerOptions AddDedicatedWorker()
+ {
+ return AddDedicatedWorker(typeof(TArgs));
+ }
+
+ public AbpBackgroundJobWorkerOptions AddDedicatedWorker()
+ {
+ return AddDedicatedWorker(typeof(TArgs1), typeof(TArgs2));
+ }
+
+ public AbpBackgroundJobWorkerOptions AddDedicatedWorker()
+ {
+ return AddDedicatedWorker(typeof(TArgs1), typeof(TArgs2), typeof(TArgs3));
+ }
+
+ protected virtual string GetDedicatedWorkerLockName(Type[] jobArgsTypes)
+ {
+ Check.NotNullOrEmpty(jobArgsTypes, nameof(jobArgsTypes));
+
+ if (jobArgsTypes.Any(t => t == null))
+ {
+ throw new ArgumentException("Job args types cannot contain null.", nameof(jobArgsTypes));
+ }
+
+ // Hash the (stable, sorted) full type names so the derived lock name stays short and within the
+ // length limits of distributed lock providers (e.g. SQL Server sp_getapplock is limited to 255 chars).
+ var key = string.Join(",", jobArgsTypes.Select(t => t.FullName).Distinct().OrderBy(n => n, StringComparer.Ordinal));
+ return "AbpBackgroundJobDedicatedWorker:" + key.ToMd5();
}
}
diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobsModule.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobsModule.cs
index b3601ad1be..0f4b20cd65 100644
--- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobsModule.cs
+++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobsModule.cs
@@ -1,4 +1,4 @@
-using System.Threading.Tasks;
+using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Volo.Abp.BackgroundWorkers;
@@ -23,9 +23,15 @@ public class AbpBackgroundJobsModule : AbpModule
{
public override async Task OnApplicationInitializationAsync(ApplicationInitializationContext context)
{
- if (context.ServiceProvider.GetRequiredService>().Value.IsJobExecutionEnabled)
+ // The manager decides (based on options) whether and which workers to run.
+ await context.AddBackgroundWorkerAsync();
+
+ // Only register the cleanup worker when it has something to do (retained successful jobs).
+ var jobOptions = context.ServiceProvider.GetRequiredService>().Value;
+ var workerOptions = context.ServiceProvider.GetRequiredService>().Value;
+ if (jobOptions.IsJobExecutionEnabled && workerOptions.StoreSuccessfulJobs && workerOptions.SuccessfulJobRetentionTime != null)
{
- await context.AddBackgroundWorkerAsync();
+ await context.AddBackgroundWorkerAsync();
}
}
diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobCleanupWorker.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobCleanupWorker.cs
new file mode 100644
index 0000000000..72c36b8ffb
--- /dev/null
+++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobCleanupWorker.cs
@@ -0,0 +1,66 @@
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Volo.Abp.BackgroundWorkers;
+using Volo.Abp.DistributedLocking;
+using Volo.Abp.Threading;
+using Volo.Abp.Timing;
+
+namespace Volo.Abp.BackgroundJobs;
+
+///
+/// Periodically deletes retained successfully completed jobs older than
+/// .
+/// Only relevant when is enabled.
+///
+public class BackgroundJobCleanupWorker : AsyncPeriodicBackgroundWorkerBase
+{
+ protected AbpBackgroundJobOptions JobOptions { get; }
+
+ protected AbpBackgroundJobWorkerOptions WorkerOptions { get; }
+
+ protected IAbpDistributedLock DistributedLock { get; }
+
+ public BackgroundJobCleanupWorker(
+ AbpAsyncTimer timer,
+ IServiceScopeFactory serviceScopeFactory,
+ IOptions jobOptions,
+ IOptions workerOptions,
+ IAbpDistributedLock distributedLock)
+ : base(timer, serviceScopeFactory)
+ {
+ JobOptions = jobOptions.Value;
+ WorkerOptions = workerOptions.Value;
+ DistributedLock = distributedLock;
+ Timer.Period = WorkerOptions.CleanSuccessfulJobsPeriod;
+ }
+
+ protected override async Task DoWorkAsync(PeriodicBackgroundWorkerContext workerContext)
+ {
+ if (!JobOptions.IsJobExecutionEnabled ||
+ !WorkerOptions.StoreSuccessfulJobs ||
+ WorkerOptions.SuccessfulJobRetentionTime == null)
+ {
+ return;
+ }
+
+ var store = workerContext.ServiceProvider.GetRequiredService();
+ var clock = workerContext.ServiceProvider.GetRequiredService();
+ var completedBefore = clock.Now.Subtract(WorkerOptions.SuccessfulJobRetentionTime.Value);
+
+ await using (var handle = await DistributedLock.TryAcquireAsync(WorkerOptions.CleanupDistributedLockName, cancellationToken: StoppingToken))
+ {
+ if (handle == null)
+ {
+ return;
+ }
+
+ int deletedCount;
+ do
+ {
+ deletedCount = await store.DeleteAsync(WorkerOptions.ApplicationName, completedBefore, WorkerOptions.MaxJobFetchCount, StoppingToken);
+ }
+ while (deletedCount > 0 && deletedCount >= WorkerOptions.MaxJobFetchCount && !StoppingToken.IsCancellationRequested);
+ }
+ }
+}
diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobInfo.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobInfo.cs
index 4a9cec922c..51f73ae7b8 100644
--- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobInfo.cs
+++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobInfo.cs
@@ -50,6 +50,14 @@ public class BackgroundJobInfo
///
public virtual bool IsAbandoned { get; set; }
+ ///
+ /// The time this job was completed successfully.
+ /// When set, the job is kept as history and excluded from the waiting jobs query.
+ /// It is only set when is enabled;
+ /// otherwise successfully completed jobs are deleted.
+ ///
+ public virtual DateTime? CompletionTime { get; set; }
+
///
/// Priority of this job.
///
diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobNameFilter.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobNameFilter.cs
new file mode 100644
index 0000000000..06c0ebf398
--- /dev/null
+++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobNameFilter.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Volo.Abp.BackgroundJobs;
+
+///
+/// Filters the waiting jobs of a background job worker by job name.
+/// A worker is exactly one of: no filter (), include-only (a dedicated worker) or
+/// exclude-only (the default worker in a multi-worker setup) — the two can never be combined.
+///
+public class BackgroundJobNameFilter
+{
+ ///
+ /// A filter that matches every job name.
+ ///
+ public static BackgroundJobNameFilter None { get; } = new(BackgroundJobNameFilterMode.None);
+
+ public BackgroundJobNameFilterMode Mode { get; }
+
+ public IReadOnlyList JobNames { get; }
+
+ public BackgroundJobNameFilter(BackgroundJobNameFilterMode mode, IReadOnlyList? jobNames = null)
+ {
+ if (!Enum.IsDefined(typeof(BackgroundJobNameFilterMode), mode))
+ {
+ throw new ArgumentException($"Invalid background job name filter mode: {mode}", nameof(mode));
+ }
+
+ var names = jobNames?.Where(x => !x.IsNullOrWhiteSpace()).Distinct(StringComparer.Ordinal).ToList() ?? new List();
+
+ if (mode == BackgroundJobNameFilterMode.None && names.Count > 0)
+ {
+ throw new ArgumentException("Job names must be empty when the filter mode is None.", nameof(jobNames));
+ }
+
+ if (mode != BackgroundJobNameFilterMode.None && names.Count == 0)
+ {
+ throw new ArgumentException("Job names cannot be empty when the filter mode is Include or Exclude.", nameof(jobNames));
+ }
+
+ Mode = mode;
+ JobNames = names.AsReadOnly();
+ }
+
+ public static BackgroundJobNameFilter Include(IReadOnlyList jobNames)
+ {
+ return new BackgroundJobNameFilter(BackgroundJobNameFilterMode.Include, jobNames);
+ }
+
+ public static BackgroundJobNameFilter Exclude(IReadOnlyList jobNames)
+ {
+ return new BackgroundJobNameFilter(BackgroundJobNameFilterMode.Exclude, jobNames);
+ }
+
+ ///
+ /// Whether the given job name passes this filter, using an ordinal (case-sensitive) comparison for the
+ /// in-memory eligibility re-check. The persistent stores translate and
+ /// into a database query instead, so their filtering follows the database collation.
+ /// Job names are expected to be unique beyond case (they are derived from the type name by default).
+ ///
+ public virtual bool IsMatch(string jobName)
+ {
+ return Mode switch
+ {
+ BackgroundJobNameFilterMode.Include => JobNames.Contains(jobName, StringComparer.Ordinal),
+ BackgroundJobNameFilterMode.Exclude => !JobNames.Contains(jobName, StringComparer.Ordinal),
+ _ => true
+ };
+ }
+}
diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobNameFilterMode.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobNameFilterMode.cs
new file mode 100644
index 0000000000..6ed2801b44
--- /dev/null
+++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobNameFilterMode.cs
@@ -0,0 +1,19 @@
+namespace Volo.Abp.BackgroundJobs;
+
+public enum BackgroundJobNameFilterMode : byte
+{
+ ///
+ /// No filter; all job names match.
+ ///
+ None = 0,
+
+ ///
+ /// Only the job names in the filter match.
+ ///
+ Include = 1,
+
+ ///
+ /// All job names except those in the filter match.
+ ///
+ Exclude = 2
+}
diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorker.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorker.cs
index a015e32d66..3053bb426f 100644
--- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorker.cs
+++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorker.cs
@@ -1,17 +1,22 @@
-using System;
+using System;
+using System.Collections.Generic;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Volo.Abp.BackgroundWorkers;
+using Volo.Abp.DependencyInjection;
using Volo.Abp.DistributedLocking;
+using Volo.Abp.ExceptionHandling;
using Volo.Abp.Threading;
using Volo.Abp.Timing;
namespace Volo.Abp.BackgroundJobs;
-public class BackgroundJobWorker : AsyncPeriodicBackgroundWorkerBase, IBackgroundJobWorker
+public class BackgroundJobWorker : IBackgroundJobWorker, ITransientDependency
{
protected AbpBackgroundJobOptions JobOptions { get; }
@@ -19,95 +24,321 @@ public class BackgroundJobWorker : AsyncPeriodicBackgroundWorkerBase, IBackgroun
protected IAbpDistributedLock DistributedLock { get; }
+ protected IServiceScopeFactory ServiceScopeFactory { get; }
+
+ protected AbpAsyncTimer Timer { get; }
+
+ public ILogger Logger { get; set; }
+
+ protected string DistributedLockName { get; set; } = default!;
+
+ protected BackgroundJobNameFilter JobNameFilter { get; set; } = BackgroundJobNameFilter.None;
+
+ protected CancellationTokenSource StoppingTokenSource { get; }
+
+ protected CancellationToken StoppingToken { get; }
+
public BackgroundJobWorker(
AbpAsyncTimer timer,
IOptions jobOptions,
IOptions workerOptions,
IServiceScopeFactory serviceScopeFactory,
IAbpDistributedLock distributedLock)
- : base(
- timer,
- serviceScopeFactory)
{
+ Timer = timer;
DistributedLock = distributedLock;
+ ServiceScopeFactory = serviceScopeFactory;
WorkerOptions = workerOptions.Value;
JobOptions = jobOptions.Value;
+ Logger = NullLogger.Instance;
+
Timer.Period = WorkerOptions.JobPollPeriod;
+ Timer.Elapsed = TimerOnElapsed;
+
+ StoppingTokenSource = new CancellationTokenSource();
+ StoppingToken = StoppingTokenSource.Token;
+ }
+
+ public virtual Task StartAsync(
+ string? distributedLockName = null,
+ BackgroundJobNameFilter? jobNameFilter = null,
+ CancellationToken cancellationToken = default)
+ {
+ DistributedLockName = distributedLockName ?? WorkerOptions.DistributedLockName;
+ JobNameFilter = jobNameFilter ?? BackgroundJobNameFilter.None;
+
+ Timer.Start(cancellationToken);
+
+ return Task.CompletedTask;
+ }
+
+ public virtual Task StopAsync(CancellationToken cancellationToken = default)
+ {
+ StoppingTokenSource.Cancel();
+ Timer.Stop(cancellationToken);
+ StoppingTokenSource.Dispose();
+
+ return Task.CompletedTask;
+ }
+
+ private async Task TimerOnElapsed(AbpAsyncTimer timer)
+ {
+ await RunAsync();
+ }
+
+ protected virtual async Task RunAsync()
+ {
+ using var scope = ServiceScopeFactory.CreateScope();
+
+ try
+ {
+ var workerContext = new PeriodicBackgroundWorkerContext(scope.ServiceProvider, StoppingToken);
+
+ if (WorkerOptions.MaxParallelJobExecutionCount > 1)
+ {
+ await ExecuteJobsInParallelAsync(workerContext);
+ }
+ else
+ {
+ await ExecuteJobsWithWorkerLockAsync(workerContext);
+ }
+ }
+ catch (Exception ex)
+ {
+ await scope.ServiceProvider
+ .GetRequiredService()
+ .NotifyAsync(new ExceptionNotificationContext(ex));
+
+ Logger.LogException(ex);
+ }
}
- protected override async Task DoWorkAsync(PeriodicBackgroundWorkerContext workerContext)
+ protected virtual async Task ExecuteJobsWithWorkerLockAsync(PeriodicBackgroundWorkerContext workerContext)
{
- await using (var handler = await DistributedLock.TryAcquireAsync(WorkerOptions.DistributedLockName, cancellationToken: StoppingToken))
+ await using (var handler = await DistributedLock.TryAcquireAsync(DistributedLockName, cancellationToken: StoppingToken))
{
if (handler != null)
{
- var store = workerContext.ServiceProvider.GetRequiredService();
+ await ExecuteWaitingJobsAsync(workerContext);
+ }
+ else
+ {
+ await WaitForNextTryAsync();
+ }
+ }
+ }
+
+ protected virtual async Task ExecuteWaitingJobsAsync(PeriodicBackgroundWorkerContext workerContext)
+ {
+ var store = workerContext.ServiceProvider.GetRequiredService();
+
+ var waitingJobs = await GetWaitingJobsAsync(workerContext, store);
- var waitingJobs = await store.GetWaitingJobsAsync(WorkerOptions.ApplicationName, WorkerOptions.MaxJobFetchCount);
+ if (!waitingJobs.Any())
+ {
+ return;
+ }
- if (!waitingJobs.Any())
+ var jobExecuter = workerContext.ServiceProvider.GetRequiredService();
+ var clock = workerContext.ServiceProvider.GetRequiredService();
+ var serializer = workerContext.ServiceProvider.GetRequiredService();
+
+ foreach (var jobInfo in waitingJobs)
+ {
+ await TryExecuteJobAsync(workerContext, store, jobInfo, jobExecuter, clock, serializer);
+ }
+ }
+
+ ///
+ /// Executes waiting jobs in parallel across application instances, up to
+ /// jobs per cycle.
+ ///
+ protected virtual async Task ExecuteJobsInParallelAsync(PeriodicBackgroundWorkerContext workerContext)
+ {
+ var store = workerContext.ServiceProvider.GetRequiredService();
+
+ var waitingJobs = await GetWaitingJobsAsync(workerContext, store);
+
+ if (!waitingJobs.Any())
+ {
+ return;
+ }
+
+ var runningTasks = new List();
+
+ // Await already-started jobs even if acquiring a lock for a later job throws,
+ // so no claimed job is left running detached from this cycle.
+ try
+ {
+ foreach (var jobInfo in waitingJobs)
+ {
+ if (runningTasks.Count >= WorkerOptions.MaxParallelJobExecutionCount || StoppingToken.IsCancellationRequested)
{
- return;
+ break;
}
- var jobExecuter = workerContext.ServiceProvider.GetRequiredService();
- var clock = workerContext.ServiceProvider.GetRequiredService();
- var serializer = workerContext.ServiceProvider.GetRequiredService();
-
- foreach (var jobInfo in waitingJobs)
+ var handle = await DistributedLock.TryAcquireAsync(GetPerJobDistributedLockName(jobInfo), cancellationToken: StoppingToken);
+ if (handle == null)
{
- jobInfo.TryCount++;
- jobInfo.LastTryTime = clock.Now;
-
- try
- {
- var jobConfiguration = JobOptions.GetJob(jobInfo.JobName);
- var jobArgs = serializer.Deserialize(jobInfo.JobArgs, jobConfiguration.ArgsType);
- var context = new JobExecutionContext(
- workerContext.ServiceProvider,
- jobConfiguration.JobType,
- jobArgs,
- workerContext.CancellationToken);
-
- try
- {
- await jobExecuter.ExecuteAsync(context);
-
- await store.DeleteAsync(jobInfo.Id);
- }
- catch (BackgroundJobExecutionException)
- {
- var nextTryTime = CalculateNextTryTime(jobInfo, clock);
-
- if (nextTryTime.HasValue)
- {
- jobInfo.NextTryTime = nextTryTime.Value;
- }
- else
- {
- jobInfo.IsAbandoned = true;
- }
-
- await TryUpdateAsync(store, jobInfo);
- }
- }
- catch (Exception ex)
- {
- Logger.LogException(ex);
- jobInfo.IsAbandoned = true;
- await TryUpdateAsync(store, jobInfo);
- }
+ // Another instance is already processing this job.
+ continue;
}
+
+ runningTasks.Add(ExecuteClaimedJobAsync(jobInfo, handle));
}
- else
+ }
+ finally
+ {
+ await Task.WhenAll(runningTasks);
+ }
+ }
+
+ protected virtual async Task ExecuteClaimedJobAsync(BackgroundJobInfo jobInfo, IAbpDistributedLockHandle handle)
+ {
+ await using (handle)
+ {
+ // Each concurrently executed job runs in its own service scope so that scoped services
+ // (e.g. the DbContext and the unit of work) are not shared across parallel jobs.
+ using var scope = ServiceScopeFactory.CreateScope();
+
+ try
{
- try
+ var workerContext = new PeriodicBackgroundWorkerContext(scope.ServiceProvider, StoppingToken);
+ var store = scope.ServiceProvider.GetRequiredService();
+ var clock = scope.ServiceProvider.GetRequiredService();
+
+ // Re-read under the lock: another instance may have completed, abandoned or rescheduled this job
+ // between fetching the waiting list and acquiring the per-job lock.
+ var currentJobInfo = await store.FindAsync(jobInfo.Id);
+ if (!IsJobEligible(currentJobInfo, clock))
{
- await Task.Delay(WorkerOptions.JobPollPeriod * 12, StoppingToken);
+ return;
}
- catch (TaskCanceledException) { }
+
+ var jobExecuter = scope.ServiceProvider.GetRequiredService();
+ var serializer = scope.ServiceProvider.GetRequiredService();
+
+ await TryExecuteJobAsync(workerContext, store, currentJobInfo, jobExecuter, clock, serializer);
+ }
+ catch (Exception ex)
+ {
+ await scope.ServiceProvider
+ .GetRequiredService()
+ .NotifyAsync(new ExceptionNotificationContext(ex));
+
+ Logger.LogException(ex);
+ }
+ }
+ }
+
+ protected virtual bool IsJobEligible(BackgroundJobInfo? jobInfo, IClock clock)
+ {
+ return jobInfo != null &&
+ jobInfo.ApplicationName == WorkerOptions.ApplicationName &&
+ !jobInfo.IsAbandoned &&
+ jobInfo.CompletionTime == null &&
+ jobInfo.NextTryTime <= clock.Now &&
+ JobNameFilter.IsMatch(jobInfo.JobName);
+ }
+
+ protected virtual string GetPerJobDistributedLockName(BackgroundJobInfo jobInfo)
+ {
+ return WorkerOptions.PerJobDistributedLockPrefix + jobInfo.Id;
+ }
+
+ protected virtual async Task> GetWaitingJobsAsync(
+ PeriodicBackgroundWorkerContext workerContext,
+ IBackgroundJobStore store)
+ {
+ return await store.GetWaitingJobsAsync(
+ WorkerOptions.ApplicationName,
+ WorkerOptions.MaxJobFetchCount,
+ JobNameFilter);
+ }
+
+ protected virtual async Task TryExecuteJobAsync(
+ PeriodicBackgroundWorkerContext workerContext,
+ IBackgroundJobStore store,
+ BackgroundJobInfo jobInfo,
+ IBackgroundJobExecuter jobExecuter,
+ IClock clock,
+ IBackgroundJobSerializer serializer)
+ {
+ jobInfo.TryCount++;
+ jobInfo.LastTryTime = clock.Now;
+
+ try
+ {
+ var jobConfiguration = JobOptions.GetJob(jobInfo.JobName);
+ var jobArgs = serializer.Deserialize(jobInfo.JobArgs, jobConfiguration.ArgsType);
+ var context = new JobExecutionContext(
+ workerContext.ServiceProvider,
+ jobConfiguration.JobType,
+ jobArgs,
+ workerContext.CancellationToken);
+
+ try
+ {
+ await jobExecuter.ExecuteAsync(context);
+
+ await HandleJobSuccessAsync(store, jobInfo, clock);
+ }
+ catch (BackgroundJobExecutionException)
+ {
+ await HandleJobFailureAsync(store, jobInfo, clock);
}
}
+ catch (Exception ex)
+ {
+ await HandleJobErrorAsync(store, jobInfo, ex);
+ }
+ }
+
+ protected virtual async Task HandleJobSuccessAsync(IBackgroundJobStore store, BackgroundJobInfo jobInfo, IClock clock)
+ {
+ if (WorkerOptions.StoreSuccessfulJobs)
+ {
+ // Keep the job as history: mark it completed instead of deleting. It is then excluded from the
+ // waiting jobs query and removed later by the retention cleanup.
+ jobInfo.CompletionTime = clock.Now;
+ await store.UpdateAsync(jobInfo);
+ }
+ else
+ {
+ await store.DeleteAsync(jobInfo.Id);
+ }
+ }
+
+ protected virtual async Task HandleJobFailureAsync(IBackgroundJobStore store, BackgroundJobInfo jobInfo, IClock clock)
+ {
+ var nextTryTime = CalculateNextTryTime(jobInfo, clock);
+
+ if (nextTryTime.HasValue)
+ {
+ jobInfo.NextTryTime = nextTryTime.Value;
+ }
+ else
+ {
+ jobInfo.IsAbandoned = true;
+ }
+
+ await TryUpdateAsync(store, jobInfo);
+ }
+
+ protected virtual async Task HandleJobErrorAsync(IBackgroundJobStore store, BackgroundJobInfo jobInfo, Exception ex)
+ {
+ Logger.LogException(ex);
+ jobInfo.IsAbandoned = true;
+ await TryUpdateAsync(store, jobInfo);
+ }
+
+ protected virtual async Task WaitForNextTryAsync()
+ {
+ try
+ {
+ await Task.Delay(WorkerOptions.JobPollPeriod * 12, StoppingToken);
+ }
+ catch (TaskCanceledException) { }
}
protected virtual async Task TryUpdateAsync(IBackgroundJobStore store, BackgroundJobInfo jobInfo)
diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorkerConfiguration.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorkerConfiguration.cs
new file mode 100644
index 0000000000..848a2a6a1c
--- /dev/null
+++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorkerConfiguration.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Volo.Abp.BackgroundJobs;
+
+///
+/// Configuration of a dedicated that processes only specific job types.
+///
+public class BackgroundJobWorkerConfiguration
+{
+ ///
+ /// A unique distributed lock name for this worker. It must be different from the names used by other workers.
+ /// It is used to serialize the worker across application instances when
+ /// is 1; in parallel mode
+ /// (greater than 1) jobs are claimed with per-job locks instead and this lock is not acquired.
+ ///
+ public string LockName { get; }
+
+ ///
+ /// The job argument types that are processed exclusively by this worker.
+ ///
+ public IReadOnlyList JobArgsTypes { get; }
+
+ public BackgroundJobWorkerConfiguration(string lockName, params Type[] jobArgsTypes)
+ {
+ LockName = Check.NotNullOrWhiteSpace(lockName, nameof(lockName));
+ Check.NotNullOrEmpty(jobArgsTypes, nameof(jobArgsTypes));
+
+ if (jobArgsTypes.Any(t => t == null))
+ {
+ throw new ArgumentException("Job args types cannot contain null.", nameof(jobArgsTypes));
+ }
+
+ JobArgsTypes = jobArgsTypes.ToList();
+ }
+}
diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorkerManager.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorkerManager.cs
new file mode 100644
index 0000000000..929c4803da
--- /dev/null
+++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorkerManager.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Volo.Abp.BackgroundWorkers;
+
+namespace Volo.Abp.BackgroundJobs;
+
+///
+/// Owns and controls the background job workers.
+/// When no is configured, a single
+/// default worker processes all jobs. Otherwise, one dedicated worker is started per configuration
+/// (each with its own distributed lock and job-type filter) plus a default worker for the remaining jobs.
+/// The workers are resolved from DI, so a replaced is respected.
+///
+public class BackgroundJobWorkerManager : IBackgroundWorker
+{
+ protected AbpBackgroundJobOptions JobOptions { get; }
+
+ protected AbpBackgroundJobWorkerOptions WorkerOptions { get; }
+
+ protected IServiceProvider ServiceProvider { get; }
+
+ protected List Workers { get; }
+
+ public BackgroundJobWorkerManager(
+ IOptions jobOptions,
+ IOptions workerOptions,
+ IServiceProvider serviceProvider)
+ {
+ JobOptions = jobOptions.Value;
+ WorkerOptions = workerOptions.Value;
+ ServiceProvider = serviceProvider;
+ Workers = new List();
+ }
+
+ public virtual async Task StartAsync(CancellationToken cancellationToken = default)
+ {
+ if (!JobOptions.IsJobExecutionEnabled)
+ {
+ return;
+ }
+
+ if (!WorkerOptions.WorkerConfigurations.Any())
+ {
+ await StartWorkerAsync(cancellationToken: cancellationToken);
+ return;
+ }
+
+ // AddDedicatedWorker already rejects duplicate job types and lock names eagerly. This is the backstop
+ // for what can only be known here: two different args types that resolve to the same job name.
+ // Validate all configurations first, so a misconfiguration does not leave already-started workers running.
+ var dedicatedWorkers = new List();
+ var allDedicatedJobNames = new List();
+
+ // The default worker uses WorkerOptions.DistributedLockName, so dedicated workers must not reuse it.
+ var lockNames = new List { WorkerOptions.DistributedLockName };
+
+ foreach (var configuration in WorkerOptions.WorkerConfigurations)
+ {
+ var jobNames = configuration.JobArgsTypes
+ .Select(GetJobName)
+ .Distinct()
+ .ToList();
+
+ var alreadyConfigured = jobNames.Intersect(allDedicatedJobNames).ToList();
+ if (alreadyConfigured.Any())
+ {
+ throw new AbpException(
+ $"The following background job(s) are configured for more than one dedicated worker: {string.Join(", ", alreadyConfigured)}. " +
+ $"Each job type can be handled by only one dedicated worker.");
+ }
+
+ if (lockNames.Contains(configuration.LockName))
+ {
+ throw new AbpException(
+ $"The distributed lock name '{configuration.LockName}' is used by more than one background job worker " +
+ $"(the default worker uses '{WorkerOptions.DistributedLockName}'). Each worker must have a unique lock name to run independently.");
+ }
+
+ lockNames.Add(configuration.LockName);
+ allDedicatedJobNames.AddRange(jobNames);
+ dedicatedWorkers.Add(new DedicatedWorkerDefinition(configuration.LockName, jobNames));
+ }
+
+ foreach (var dedicatedWorker in dedicatedWorkers)
+ {
+ await StartWorkerAsync(dedicatedWorker.LockName, BackgroundJobNameFilter.Include(dedicatedWorker.JobNames), cancellationToken);
+ }
+
+ // Default worker processes every job that is not handled by a dedicated worker.
+ await StartWorkerAsync(null, BackgroundJobNameFilter.Exclude(allDedicatedJobNames), cancellationToken);
+ }
+
+ protected virtual string GetJobName(Type argsType)
+ {
+ try
+ {
+ return JobOptions.GetJob(argsType).JobName;
+ }
+ catch (AbpException ex)
+ {
+ throw new AbpException(
+ $"No background job is registered for the args type '{argsType.FullName}' configured via AddDedicatedWorker. " +
+ $"Register the job before configuring a dedicated worker for it.", ex);
+ }
+ }
+
+ protected virtual async Task StartWorkerAsync(
+ string? distributedLockName = null,
+ BackgroundJobNameFilter? jobNameFilter = null,
+ CancellationToken cancellationToken = default)
+ {
+ var worker = ServiceProvider.GetRequiredService();
+ await worker.StartAsync(distributedLockName, jobNameFilter, cancellationToken);
+ Workers.Add(worker);
+ }
+
+ public virtual async Task StopAsync(CancellationToken cancellationToken = default)
+ {
+ foreach (var worker in Workers)
+ {
+ await worker.StopAsync(cancellationToken);
+ }
+
+ Workers.Clear();
+ }
+}
diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/DedicatedWorkerDefinition.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/DedicatedWorkerDefinition.cs
new file mode 100644
index 0000000000..ba98c2c1dc
--- /dev/null
+++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/DedicatedWorkerDefinition.cs
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+
+namespace Volo.Abp.BackgroundJobs;
+
+///
+/// A validated, ready-to-start dedicated worker: the distributed lock name it runs under and the
+/// resolved job names it is responsible for. Built by from a
+/// after all configurations have been validated.
+///
+public class DedicatedWorkerDefinition
+{
+ ///
+ /// The distributed lock name this worker runs under.
+ ///
+ public string LockName { get; }
+
+ ///
+ /// The resolved job names this worker is responsible for.
+ ///
+ public IReadOnlyList JobNames { get; }
+
+ public DedicatedWorkerDefinition(string lockName, IReadOnlyList jobNames)
+ {
+ LockName = lockName;
+ JobNames = jobNames;
+ }
+}
diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobStore.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobStore.cs
index 7fcf8cc5b6..421ec55434 100644
--- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobStore.cs
+++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobStore.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
namespace Volo.Abp.BackgroundJobs;
@@ -24,7 +25,7 @@ public interface IBackgroundJobStore
///
/// Gets waiting jobs. It should get jobs based on these:
- /// Conditions: ApplicationName is applicationName And !IsAbandoned And NextTryTime <= Clock.Now.
+ /// Conditions: ApplicationName is applicationName And !IsAbandoned And CompletionTime == null And NextTryTime <= Clock.Now.
/// Order by: Priority DESC, TryCount ASC, NextTryTime ASC.
/// Maximum result: .
///
@@ -32,12 +33,35 @@ public interface IBackgroundJobStore
/// Maximum result count.
Task> GetWaitingJobsAsync(string? applicationName, int maxResultCount);
+ ///
+ /// Gets waiting jobs (see ), additionally filtered by job name
+ /// according to .
+ ///
+ /// Application name.
+ /// Maximum result count.
+ /// Job name filter. When null, no job name filter is applied.
+ Task> GetWaitingJobsAsync(
+ string? applicationName,
+ int maxResultCount,
+ BackgroundJobNameFilter? jobNameFilter);
+
///
/// Deletes a job.
///
/// The Job Unique Identifier.
Task DeleteAsync(Guid jobId);
+ ///
+ /// Deletes successfully completed jobs ( is set) of the given
+ /// application that completed before . Used by the retention cleanup.
+ ///
+ /// The number of deleted jobs.
+ Task DeleteAsync(
+ string? applicationName,
+ DateTime completedBefore,
+ int maxResultCount,
+ CancellationToken cancellationToken = default);
+
///
/// Updates a job.
///
diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobWorker.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobWorker.cs
index ecae732b8d..9aa4ac25a8 100644
--- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobWorker.cs
+++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobWorker.cs
@@ -1,8 +1,27 @@
-using Volo.Abp.BackgroundWorkers;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
namespace Volo.Abp.BackgroundJobs;
-public interface IBackgroundJobWorker : IBackgroundWorker
+///
+/// A background job worker that polls and executes waiting jobs.
+/// Instances are created, configured and started by .
+///
+public interface IBackgroundJobWorker
{
+ ///
+ /// Starts this worker.
+ ///
+ ///
+ /// Distributed lock name for this worker. When null, is used.
+ ///
+ /// Filters the jobs this worker processes by name. When null, all jobs are processed.
+ /// Cancellation token.
+ Task StartAsync(
+ string? distributedLockName = null,
+ BackgroundJobNameFilter? jobNameFilter = null,
+ CancellationToken cancellationToken = default);
+ Task StopAsync(CancellationToken cancellationToken = default);
}
diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/InMemoryBackgroundJobStore.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/InMemoryBackgroundJobStore.cs
index 85916e8c37..d0fa34e7e8 100644
--- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/InMemoryBackgroundJobStore.cs
+++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/InMemoryBackgroundJobStore.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Timing;
@@ -37,9 +38,20 @@ public class InMemoryBackgroundJobStore : IBackgroundJobStore, ISingletonDepende
public virtual Task> GetWaitingJobsAsync(string? applicationName, int maxResultCount)
{
+ return GetWaitingJobsAsync(applicationName, maxResultCount, null);
+ }
+
+ public virtual Task> GetWaitingJobsAsync(
+ string? applicationName,
+ int maxResultCount,
+ BackgroundJobNameFilter? jobNameFilter)
+ {
+ var filter = jobNameFilter ?? BackgroundJobNameFilter.None;
+
var waitingJobs = _jobs.Values
.Where(t => t.ApplicationName == applicationName)
- .Where(t => !t.IsAbandoned && t.NextTryTime <= Clock.Now)
+ .Where(t => !t.IsAbandoned && t.CompletionTime == null && t.NextTryTime <= Clock.Now)
+ .Where(t => filter.IsMatch(t.JobName))
.OrderByDescending(t => t.Priority)
.ThenBy(t => t.TryCount)
.ThenBy(t => t.NextTryTime)
@@ -57,6 +69,28 @@ public class InMemoryBackgroundJobStore : IBackgroundJobStore, ISingletonDepende
return Task.CompletedTask;
}
+ public virtual Task DeleteAsync(
+ string? applicationName,
+ DateTime completedBefore,
+ int maxResultCount,
+ CancellationToken cancellationToken = default)
+ {
+ var idsToDelete = _jobs.Values
+ .Where(t => t.ApplicationName == applicationName)
+ .Where(t => t.CompletionTime != null && t.CompletionTime < completedBefore)
+ .OrderBy(t => t.CompletionTime)
+ .Take(maxResultCount)
+ .Select(t => t.Id)
+ .ToList();
+
+ foreach (var id in idsToDelete)
+ {
+ _jobs.TryRemove(id, out _);
+ }
+
+ return Task.FromResult(idsToDelete.Count);
+ }
+
public virtual Task UpdateAsync(BackgroundJobInfo jobInfo)
{
if (jobInfo.IsAbandoned)
diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/HttpMethodHelper.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/HttpMethodHelper.cs
index 78ee75e901..1ef6961c29 100644
--- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/HttpMethodHelper.cs
+++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/HttpMethodHelper.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
@@ -8,15 +8,25 @@ namespace Volo.Abp.Http;
public static class HttpMethodHelper
{
- public const string DefaultHttpVerb = "POST";
+ public const string Get = "GET";
+ public const string Post = "POST";
+ public const string Put = "PUT";
+ public const string Delete = "DELETE";
+ public const string Patch = "PATCH";
+ public const string Head = "HEAD";
+ public const string Options = "OPTIONS";
+ public const string Trace = "TRACE";
+ public const string Query = "QUERY";
+
+ public const string DefaultHttpVerb = Post;
public static Dictionary ConventionalPrefixes { get; set; } = new Dictionary
{
- {"GET", new[] {"GetList", "GetAll", "Get"}},
- {"PUT", new[] {"Put", "Update"}},
- {"DELETE", new[] {"Delete", "Remove"}},
- {"POST", new[] {"Create", "Add", "Insert", "Post"}},
- {"PATCH", new[] {"Patch"}}
+ {Get, new[] {"GetList", "GetAll", "Get"}},
+ {Put, new[] {"Put", "Update"}},
+ {Delete, new[] {"Delete", "Remove"}},
+ {Post, new[] {"Create", "Add", "Insert", "Post"}},
+ {Patch, new[] {"Patch"}}
};
public static string GetConventionalVerbForMethodName(string methodName)
@@ -50,24 +60,44 @@ public static class HttpMethodHelper
{
switch (httpMethod?.ToUpperInvariant())
{
- case "GET":
+ case Get:
return HttpMethod.Get;
- case "POST":
+ case Post:
return HttpMethod.Post;
- case "PUT":
+ case Put:
return HttpMethod.Put;
- case "DELETE":
+ case Delete:
return HttpMethod.Delete;
- case "OPTIONS":
+ case Options:
return HttpMethod.Options;
- case "TRACE":
+ case Trace:
return HttpMethod.Trace;
- case "HEAD":
+ case Head:
return HttpMethod.Head;
- case "PATCH":
- return new HttpMethod("PATCH");
+ case Patch:
+ return new HttpMethod(Patch);
+ case Query:
+ return new HttpMethod(Query);
default:
throw new AbpException("Unknown HTTP METHOD: " + httpMethod);
}
}
+
+ public static bool IsGet(string? httpMethod) => string.Equals(httpMethod, Get, StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsPost(string? httpMethod) => string.Equals(httpMethod, Post, StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsPut(string? httpMethod) => string.Equals(httpMethod, Put, StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsDelete(string? httpMethod) => string.Equals(httpMethod, Delete, StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsPatch(string? httpMethod) => string.Equals(httpMethod, Patch, StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsHead(string? httpMethod) => string.Equals(httpMethod, Head, StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsOptions(string? httpMethod) => string.Equals(httpMethod, Options, StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsTrace(string? httpMethod) => string.Equals(httpMethod, Trace, StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsQuery(string? httpMethod) => string.Equals(httpMethod, Query, StringComparison.OrdinalIgnoreCase);
}
diff --git a/framework/src/Volo.Abp.Security/Volo/Abp/Security/Claims/CurrentPrincipalAccessorBase.cs b/framework/src/Volo.Abp.Security/Volo/Abp/Security/Claims/CurrentPrincipalAccessorBase.cs
index 1d3e296e38..48e9801049 100644
--- a/framework/src/Volo.Abp.Security/Volo/Abp/Security/Claims/CurrentPrincipalAccessorBase.cs
+++ b/framework/src/Volo.Abp.Security/Volo/Abp/Security/Claims/CurrentPrincipalAccessorBase.cs
@@ -8,7 +8,7 @@ public abstract class CurrentPrincipalAccessorBase : ICurrentPrincipalAccessor
{
public ClaimsPrincipal Principal => _currentPrincipal.Value ?? GetClaimsPrincipal();
- private readonly AsyncLocal _currentPrincipal = new AsyncLocal();
+ private readonly AsyncLocal _currentPrincipal = new AsyncLocal();
protected abstract ClaimsPrincipal GetClaimsPrincipal();
@@ -19,10 +19,10 @@ public abstract class CurrentPrincipalAccessorBase : ICurrentPrincipalAccessor
private IDisposable SetCurrent(ClaimsPrincipal principal)
{
- var parent = Principal;
+ var parent = _currentPrincipal.Value;
_currentPrincipal.Value = principal;
- return new DisposeAction, ClaimsPrincipal>>(static (state) =>
+ return new DisposeAction, ClaimsPrincipal?>>(static (state) =>
{
var (currentPrincipal, parent) = state;
currentPrincipal.Value = parent;
diff --git a/framework/src/Volo.Abp.Security/Volo/Abp/Security/Claims/ThreadCurrentPrincipalAccessor.cs b/framework/src/Volo.Abp.Security/Volo/Abp/Security/Claims/ThreadCurrentPrincipalAccessor.cs
index ab42441f98..99e8536eec 100644
--- a/framework/src/Volo.Abp.Security/Volo/Abp/Security/Claims/ThreadCurrentPrincipalAccessor.cs
+++ b/framework/src/Volo.Abp.Security/Volo/Abp/Security/Claims/ThreadCurrentPrincipalAccessor.cs
@@ -8,6 +8,12 @@ public class ThreadCurrentPrincipalAccessor : CurrentPrincipalAccessorBase, ISin
{
protected override ClaimsPrincipal GetClaimsPrincipal()
{
- return (Thread.CurrentPrincipal as ClaimsPrincipal)!;
+ var principal = Thread.CurrentPrincipal;
+ if (principal == null)
+ {
+ return new ClaimsPrincipal(new ClaimsIdentity());
+ }
+
+ return principal as ClaimsPrincipal ?? new ClaimsPrincipal(principal);
}
}
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Auditing/AuditTestController_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Auditing/AuditTestController_Tests.cs
index dac9259368..02093e0343 100644
--- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Auditing/AuditTestController_Tests.cs
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Auditing/AuditTestController_Tests.cs
@@ -58,6 +58,20 @@ public class AuditTestController_Tests : AspNetCoreMvcTestBase
await _auditingStore.Received().DidNotReceive().SaveAsync(Arg.Any());
}
+ [Fact]
+ public async Task Should_Disable_AuditLog_For_Query_Requests()
+ {
+ _options.IsEnabledForGetRequests = false;
+
+ using (var requestMessage = new HttpRequestMessage(new HttpMethod("QUERY"), "api/audit-test/audit-success"))
+ {
+ var response = await Client.SendAsync(requestMessage);
+ response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
+ }
+
+ await _auditingStore.Received().DidNotReceive().SaveAsync(Arg.Any());
+ }
+
[Fact]
public async Task Should_Trigger_Middleware_And_AuditLog_Success_For_GetRequests()
{
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs
index 57e01df4f9..05e5d7b524 100644
--- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs
@@ -1,4 +1,5 @@
-using System.Threading.Tasks;
+using System.Net.Http;
+using System.Threading.Tasks;
using Shouldly;
using Xunit;
@@ -18,4 +19,12 @@ public class UnitOfWorkMiddleware_Tests : AspNetCoreMvcTestBase
var result = await Client.PostAsync("/api/unitofwork-test/ActionRequiresUowPost", null);
result.IsSuccessStatusCode.ShouldBeTrue();
}
+
+ [Fact]
+ public async Task Query_Actions_Should_Not_Be_Transactional()
+ {
+ using var requestMessage = new HttpRequestMessage(new HttpMethod("QUERY"), "/api/unitofwork-test/ActionRequiresUowQuery");
+ var result = await Client.SendAsync(requestMessage);
+ result.IsSuccessStatusCode.ShouldBeTrue();
+ }
}
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkPageFilter_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkPageFilter_Tests.cs
index 6de88f20a8..43bca30e20 100644
--- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkPageFilter_Tests.cs
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkPageFilter_Tests.cs
@@ -1,4 +1,5 @@
-using System.Threading.Tasks;
+using System.Net.Http;
+using System.Threading.Tasks;
using Shouldly;
using Xunit;
@@ -18,4 +19,12 @@ public class UnitOfWorkPageFilter_Tests : AspNetCoreMvcTestBase
var result = await Client.PostAsync("/Uow/UnitOfWorkTestPage?handler=RequiresUow", null);
result.IsSuccessStatusCode.ShouldBeTrue();
}
+
+ [Fact]
+ public async Task Query_Actions_Should_Not_Be_Transactional()
+ {
+ using var requestMessage = new HttpRequestMessage(new HttpMethod("QUERY"), "/Uow/UnitOfWorkTestPage?handler=RequiresUow");
+ var result = await Client.SendAsync(requestMessage);
+ result.IsSuccessStatusCode.ShouldBeTrue();
+ }
}
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs
index bf05c55a31..ebf2c12a6a 100644
--- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs
@@ -34,6 +34,16 @@ public class UnitOfWorkTestController : AbpController
return Content("OK");
}
+ [AcceptVerbs("QUERY")]
+ [Route("ActionRequiresUowQuery")]
+ public ActionResult ActionRequiresUowQuery()
+ {
+ CurrentUnitOfWork.ShouldNotBeNull();
+ CurrentUnitOfWork.Options.IsTransactional.ShouldBeFalse();
+
+ return Content("OK");
+ }
+
[HttpGet]
[Route("HandledException")]
[UnitOfWork(isTransactional: true)]
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestPage.cshtml.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestPage.cshtml.cs
index ebf9e6cfd4..b0ce556405 100644
--- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestPage.cshtml.cs
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestPage.cshtml.cs
@@ -31,6 +31,14 @@ public class UnitOfWorkTestPage : AbpPageModel
return Content("OK");
}
+ public IActionResult OnQueryRequiresUow()
+ {
+ CurrentUnitOfWork.ShouldNotBeNull();
+ CurrentUnitOfWork.Options.IsTransactional.ShouldBeFalse();
+
+ return Content("OK");
+ }
+
[UnitOfWork(isTransactional: true)]
public ObjectResult OnGetHandledException()
{
diff --git a/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider_Tests.cs
new file mode 100644
index 0000000000..e5369e688e
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider_Tests.cs
@@ -0,0 +1,32 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Xunit;
+
+namespace Volo.Abp.AspNetCore.Uow;
+
+public class AspNetCoreUnitOfWorkTransactionBehaviourProvider_Tests
+{
+ private static AspNetCoreUnitOfWorkTransactionBehaviourProvider CreateProvider(string method)
+ {
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Method = method;
+
+ return new AspNetCoreUnitOfWorkTransactionBehaviourProvider(
+ new HttpContextAccessor { HttpContext = httpContext },
+ Microsoft.Extensions.Options.Options.Create(new AspNetCoreUnitOfWorkTransactionBehaviourProviderOptions()));
+ }
+
+ [Theory]
+ [InlineData("GET", false)]
+ [InlineData("QUERY", false)]
+ [InlineData("query", false)]
+ [InlineData("HEAD", true)]
+ [InlineData("POST", true)]
+ [InlineData("PUT", true)]
+ [InlineData("DELETE", true)]
+ public void IsTransactional_Should_Treat_Get_And_Query_As_Non_Transactional(string method, bool expected)
+ {
+ CreateProvider(method).IsTransactional.ShouldBe(expected);
+ }
+}
diff --git a/framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/AuditingInterceptor_HttpMethod_Tests.cs b/framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/AuditingInterceptor_HttpMethod_Tests.cs
new file mode 100644
index 0000000000..8216448333
--- /dev/null
+++ b/framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/AuditingInterceptor_HttpMethod_Tests.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using NSubstitute;
+using Xunit;
+
+namespace Volo.Abp.Auditing;
+
+public class AuditingInterceptor_HttpMethod_Tests : AbpAuditingTestBase
+{
+ protected IAuditingStore AuditingStore;
+
+ private string? _httpMethod;
+
+ protected override void AfterAddApplication(IServiceCollection services)
+ {
+ AuditingStore = Substitute.For();
+ services.Replace(ServiceDescriptor.Singleton(AuditingStore));
+
+ services.Configure(options =>
+ {
+ options.IsEnabledForGetRequests = false;
+ options.Contributors.Add(new TestHttpMethodAuditContributor(() => _httpMethod));
+ });
+ }
+
+ [Fact]
+ public async Task Should_Not_Write_AuditLog_For_Query_Http_Method_Without_Explicit_Scope()
+ {
+ _httpMethod = "QUERY";
+
+ var auditedObject = GetRequiredService();
+ await auditedObject.DoItAsync(new Auditing_Tests.InputObject { Value1 = "x", Value2 = 1 });
+
+ await AuditingStore.DidNotReceive().SaveAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task Should_Write_AuditLog_For_Post_Http_Method_Without_Explicit_Scope()
+ {
+ _httpMethod = "POST";
+
+ var auditedObject = GetRequiredService();
+ await auditedObject.DoItAsync(new Auditing_Tests.InputObject { Value1 = "x", Value2 = 1 });
+
+ await AuditingStore.Received().SaveAsync(Arg.Any());
+ }
+
+ public class TestHttpMethodAuditContributor : AuditLogContributor
+ {
+ private readonly Func _httpMethodFactory;
+
+ public TestHttpMethodAuditContributor(Func httpMethodFactory)
+ {
+ _httpMethodFactory = httpMethodFactory;
+ }
+
+ public override void PreContribute(AuditLogContributionContext context)
+ {
+ context.AuditInfo.HttpMethod = _httpMethodFactory();
+ }
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpAutoLockNameWorkerTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpAutoLockNameWorkerTestModule.cs
new file mode 100644
index 0000000000..38b640b84b
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpAutoLockNameWorkerTestModule.cs
@@ -0,0 +1,27 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Volo.Abp.Autofac;
+using Volo.Abp.Modularity;
+
+namespace Volo.Abp.BackgroundJobs;
+
+[DependsOn(
+ typeof(AbpBackgroundJobsModule),
+ typeof(AbpAutofacModule),
+ typeof(AbpTestBaseModule)
+)]
+public class AbpAutoLockNameWorkerTestModule : AbpModule
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ context.Services.AddSingleton();
+ context.Services.Replace(ServiceDescriptor.Transient());
+
+ // No lock name is given: it is derived from the job argument type names.
+ Configure(options =>
+ {
+ options.AddDedicatedWorker();
+ options.AddDedicatedWorker();
+ });
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobCleanupTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobCleanupTestModule.cs
new file mode 100644
index 0000000000..b29c60784e
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobCleanupTestModule.cs
@@ -0,0 +1,23 @@
+using System;
+using Volo.Abp.Autofac;
+using Volo.Abp.Modularity;
+
+namespace Volo.Abp.BackgroundJobs;
+
+[DependsOn(
+ typeof(AbpBackgroundJobsModule),
+ typeof(AbpAutofacModule),
+ typeof(AbpTestBaseModule)
+)]
+public class AbpBackgroundJobCleanupTestModule : AbpModule
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ // IsJobExecutionEnabled is true by default, which the cleanup worker requires.
+ Configure(options =>
+ {
+ options.StoreSuccessfulJobs = true;
+ options.SuccessfulJobRetentionTime = TimeSpan.FromDays(1);
+ });
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerTestModule.cs
new file mode 100644
index 0000000000..db56843a4b
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerTestModule.cs
@@ -0,0 +1,25 @@
+using Microsoft.Extensions.DependencyInjection;
+using Volo.Abp.Autofac;
+using Volo.Abp.Modularity;
+
+namespace Volo.Abp.BackgroundJobs;
+
+[DependsOn(
+ typeof(AbpBackgroundJobsModule),
+ typeof(AbpAutofacModule),
+ typeof(AbpTestBaseModule)
+)]
+public class AbpBackgroundJobWorkerTestModule : AbpModule
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ // Drive the worker manually in tests; don't run the real periodic worker.
+ Configure(options =>
+ {
+ options.IsJobExecutionEnabled = false;
+ });
+
+ context.Services.AddSingleton();
+ context.Services.AddScoped();
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpDuplicateLockNameTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpDuplicateLockNameTestModule.cs
new file mode 100644
index 0000000000..6e35c81dfc
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpDuplicateLockNameTestModule.cs
@@ -0,0 +1,27 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Volo.Abp.Autofac;
+using Volo.Abp.Modularity;
+
+namespace Volo.Abp.BackgroundJobs;
+
+[DependsOn(
+ typeof(AbpBackgroundJobsModule),
+ typeof(AbpAutofacModule),
+ typeof(AbpTestBaseModule)
+)]
+public class AbpDuplicateLockNameTestModule : AbpModule
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ context.Services.AddSingleton();
+ context.Services.Replace(ServiceDescriptor.Transient());
+
+ // Two workers with different job types but the same lock name, which must fail at initialization.
+ Configure(options =>
+ {
+ options.AddDedicatedWorker("dup-lock");
+ options.AddDedicatedWorker("dup-lock");
+ });
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpDuplicateWorkerTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpDuplicateWorkerTestModule.cs
new file mode 100644
index 0000000000..f4d9aae208
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpDuplicateWorkerTestModule.cs
@@ -0,0 +1,27 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Volo.Abp.Autofac;
+using Volo.Abp.Modularity;
+
+namespace Volo.Abp.BackgroundJobs;
+
+[DependsOn(
+ typeof(AbpBackgroundJobsModule),
+ typeof(AbpAutofacModule),
+ typeof(AbpTestBaseModule)
+)]
+public class AbpDuplicateWorkerTestModule : AbpModule
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ context.Services.AddSingleton();
+ context.Services.Replace(ServiceDescriptor.Transient());
+
+ // The same job type is assigned to two dedicated workers, which must fail at initialization.
+ Configure(options =>
+ {
+ options.AddDedicatedWorker("lock-a");
+ options.AddDedicatedWorker("lock-b");
+ });
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpMultiWorkerTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpMultiWorkerTestModule.cs
new file mode 100644
index 0000000000..d437c9325f
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpMultiWorkerTestModule.cs
@@ -0,0 +1,28 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Volo.Abp.Autofac;
+using Volo.Abp.Modularity;
+
+namespace Volo.Abp.BackgroundJobs;
+
+[DependsOn(
+ typeof(AbpBackgroundJobsModule),
+ typeof(AbpAutofacModule),
+ typeof(AbpTestBaseModule)
+)]
+public class AbpMultiWorkerTestModule : AbpModule
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ context.Services.AddSingleton();
+
+ // Replace the real worker with a recording one to assert how the manager resolves and starts workers.
+ context.Services.Replace(ServiceDescriptor.Transient());
+
+ Configure(options =>
+ {
+ options.AddDedicatedWorker("lock-a");
+ options.AddDedicatedWorker("lock-b");
+ });
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpSameJobNameTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpSameJobNameTestModule.cs
new file mode 100644
index 0000000000..f4ddbb8be8
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpSameJobNameTestModule.cs
@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Volo.Abp.Autofac;
+using Volo.Abp.Modularity;
+
+namespace Volo.Abp.BackgroundJobs;
+
+[DependsOn(
+ typeof(AbpBackgroundJobsModule),
+ typeof(AbpAutofacModule),
+ typeof(AbpTestBaseModule)
+)]
+public class AbpSameJobNameTestModule : AbpModule
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ context.Services.AddSingleton();
+ context.Services.Replace(ServiceDescriptor.Transient());
+
+ // Two dedicated workers with different args types that both resolve to "shared-job-name".
+ // AddDedicatedWorker's eager check compares by type (both pass); only the manager's backstop
+ // (which resolves job names) can catch this.
+ Configure(options =>
+ {
+ options.AddDedicatedWorker("lock-a");
+ options.AddDedicatedWorker("lock-b");
+ });
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobCleanupWorker_Tests.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobCleanupWorker_Tests.cs
new file mode 100644
index 0000000000..a7a638ed7b
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobCleanupWorker_Tests.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Volo.Abp.BackgroundWorkers;
+using Volo.Abp.DistributedLocking;
+using Volo.Abp.Testing;
+using Volo.Abp.Threading;
+using Volo.Abp.Timing;
+using Xunit;
+
+namespace Volo.Abp.BackgroundJobs;
+
+public class BackgroundJobCleanupWorker_Tests : AbpIntegratedTest
+{
+ private readonly IBackgroundJobStore _store;
+ private readonly IClock _clock;
+ private readonly AbpBackgroundJobWorkerOptions _workerOptions;
+
+ public BackgroundJobCleanupWorker_Tests()
+ {
+ _store = GetRequiredService();
+ _clock = GetRequiredService();
+ _workerOptions = GetRequiredService>().Value;
+ }
+
+ protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
+ {
+ options.UseAutofac();
+ }
+
+ private TestableBackgroundJobCleanupWorker CreateWorker()
+ {
+ return new TestableBackgroundJobCleanupWorker(
+ GetRequiredService(),
+ GetRequiredService(),
+ GetRequiredService>(),
+ GetRequiredService>(),
+ GetRequiredService());
+ }
+
+ private Task RunCleanupAsync()
+ {
+ return CreateWorker().DoWorkPublicAsync(new PeriodicBackgroundWorkerContext(ServiceProvider));
+ }
+
+ private async Task InsertCompletedJobAsync(DateTime completionTime)
+ {
+ var id = Guid.NewGuid();
+ await _store.InsertAsync(new BackgroundJobInfo
+ {
+ Id = id,
+ JobName = "job-a",
+ JobArgs = "{}",
+ CreationTime = _clock.Now,
+ NextTryTime = _clock.Now,
+ CompletionTime = completionTime
+ });
+ return id;
+ }
+
+ [Fact]
+ public async Task Should_Delete_Completed_Jobs_Older_Than_Retention()
+ {
+ // Retention is 1 day (configured in the module).
+ var oldJobId = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(2)));
+ var recentJobId = await InsertCompletedJobAsync(_clock.Now);
+
+ await RunCleanupAsync();
+
+ (await _store.FindAsync(oldJobId)).ShouldBeNull(); // older than retention → deleted
+ (await _store.FindAsync(recentJobId)).ShouldNotBeNull(); // within retention → kept
+ }
+
+ [Fact]
+ public async Task Should_Not_Delete_When_StoreSuccessfulJobs_Disabled()
+ {
+ _workerOptions.StoreSuccessfulJobs = false;
+
+ var oldJobId = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(2)));
+
+ await RunCleanupAsync();
+
+ (await _store.FindAsync(oldJobId)).ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task Should_Not_Delete_When_Retention_Is_Null()
+ {
+ _workerOptions.SuccessfulJobRetentionTime = null;
+
+ var oldJobId = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(2)));
+
+ await RunCleanupAsync();
+
+ (await _store.FindAsync(oldJobId)).ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task Should_Delete_All_Old_Jobs_In_Batches()
+ {
+ _workerOptions.MaxJobFetchCount = 2;
+
+ var ids = new List();
+ for (var i = 0; i < 5; i++)
+ {
+ ids.Add(await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(2))));
+ }
+
+ await RunCleanupAsync();
+
+ foreach (var id in ids)
+ {
+ (await _store.FindAsync(id)).ShouldBeNull();
+ }
+ }
+
+ [Fact]
+ public async Task Should_Delete_Oldest_Completed_Jobs_First_When_Limited_By_MaxResultCount()
+ {
+ var oldest = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(5)));
+ var middle = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(3)));
+ var newest = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(1)));
+
+ // All three are completed before now, but a single call may delete only two.
+ var deletedCount = await _store.DeleteAsync(null, _clock.Now, maxResultCount: 2);
+
+ deletedCount.ShouldBe(2);
+ (await _store.FindAsync(oldest)).ShouldBeNull();
+ (await _store.FindAsync(middle)).ShouldBeNull();
+ (await _store.FindAsync(newest)).ShouldNotBeNull(); // newest survives, proving oldest-first deletion
+ }
+
+ [Fact]
+ public async Task Should_Not_Loop_Forever_When_MaxJobFetchCount_Is_Zero()
+ {
+ _workerOptions.MaxJobFetchCount = 0;
+
+ var oldJobId = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(2)));
+
+ // Must return (not hang) even though nothing can be fetched/deleted with a zero page size.
+ await RunCleanupAsync();
+
+ (await _store.FindAsync(oldJobId)).ShouldNotBeNull();
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorkerTestBase.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorkerTestBase.cs
new file mode 100644
index 0000000000..2824c1a231
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorkerTestBase.cs
@@ -0,0 +1,11 @@
+using Volo.Abp.Testing;
+
+namespace Volo.Abp.BackgroundJobs;
+
+public abstract class BackgroundJobWorkerTestBase : AbpIntegratedTest
+{
+ protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
+ {
+ options.UseAutofac();
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_AutoLockName_Tests.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_AutoLockName_Tests.cs
new file mode 100644
index 0000000000..76e4734b05
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_AutoLockName_Tests.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Linq;
+using Shouldly;
+using Volo.Abp.Testing;
+using Xunit;
+
+namespace Volo.Abp.BackgroundJobs;
+
+public class BackgroundJobWorker_AutoLockName_Tests : AbpIntegratedTest
+{
+ protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
+ {
+ options.UseAutofac();
+ }
+
+ [Fact]
+ public void Should_Derive_Bounded_Lock_Name_From_Job_Args_Types()
+ {
+ var records = GetRequiredService().Records;
+
+ var dedicated = records.Where(r => r.JobNameFilter?.Mode == BackgroundJobNameFilterMode.Include).ToList();
+ dedicated.Count.ShouldBe(2);
+
+ // Lock name is derived (prefix + MD5 of the full type name), so it is stable and length-bounded.
+ var expectedA = "AbpBackgroundJobDedicatedWorker:" + typeof(WorkerJobAArgs).FullName!.ToMd5();
+ var expectedB = "AbpBackgroundJobDedicatedWorker:" + typeof(WorkerJobBArgs).FullName!.ToMd5();
+
+ dedicated.ShouldContain(r => r.DistributedLockName == expectedA);
+ dedicated.ShouldContain(r => r.DistributedLockName == expectedB);
+
+ // Bounded length regardless of how long the type names are.
+ dedicated.ShouldAllBe(r => r.DistributedLockName!.Length <= 64);
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_DuplicateConfiguration_Tests.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_DuplicateConfiguration_Tests.cs
new file mode 100644
index 0000000000..bdd80efa7a
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_DuplicateConfiguration_Tests.cs
@@ -0,0 +1,63 @@
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Volo.Abp.Autofac;
+using Xunit;
+
+namespace Volo.Abp.BackgroundJobs;
+
+public class BackgroundJobWorker_DuplicateConfiguration_Tests
+{
+ [Fact]
+ public void Should_Throw_Without_Starting_Any_Worker_When_A_Job_Type_Is_Assigned_To_Multiple_Workers()
+ {
+ using var application = AbpApplicationFactory.Create(options =>
+ {
+ options.UseAutofac();
+ });
+
+ var exception = Record.Exception(() => application.Initialize());
+
+ exception.ShouldNotBeNull();
+ exception.ToString().ShouldContain("dedicated worker");
+
+ // Validation must happen before any worker is started.
+ var recorder = application.ServiceProvider.GetRequiredService();
+ recorder.Records.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Throw_Without_Starting_Any_Worker_When_Two_Workers_Share_A_Lock_Name()
+ {
+ using var application = AbpApplicationFactory.Create(options =>
+ {
+ options.UseAutofac();
+ });
+
+ var exception = Record.Exception(() => application.Initialize());
+
+ exception.ShouldNotBeNull();
+ exception.ToString().ShouldContain("lock name");
+
+ var recorder = application.ServiceProvider.GetRequiredService();
+ recorder.Records.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Throw_Without_Starting_Any_Worker_When_Different_Args_Types_Resolve_To_The_Same_Job_Name()
+ {
+ using var application = AbpApplicationFactory.Create(options =>
+ {
+ options.UseAutofac();
+ });
+
+ // The two args types pass the eager (by-type) check but resolve to the same job name,
+ // so only the manager's backstop validation can reject them.
+ var exception = Record.Exception(() => application.Initialize());
+
+ exception.ShouldNotBeNull();
+ exception.ToString().ShouldContain("more than one dedicated worker");
+
+ var recorder = application.ServiceProvider.GetRequiredService();
+ recorder.Records.ShouldBeEmpty();
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_MultiWorkerRegistration_Tests.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_MultiWorkerRegistration_Tests.cs
new file mode 100644
index 0000000000..8279d2d493
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_MultiWorkerRegistration_Tests.cs
@@ -0,0 +1,37 @@
+using System.Linq;
+using Shouldly;
+using Volo.Abp.Testing;
+using Xunit;
+
+namespace Volo.Abp.BackgroundJobs;
+
+public class BackgroundJobWorker_MultiWorkerRegistration_Tests : AbpIntegratedTest
+{
+ protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
+ {
+ options.UseAutofac();
+ }
+
+ [Fact]
+ public void Should_Start_Dedicated_Workers_And_A_Default_Worker()
+ {
+ // The workers are resolved from DI (RecordingBackgroundJobWorker replaces the real one),
+ // proving the manager honors the registered/replaced IBackgroundJobWorker.
+ var records = GetRequiredService().Records;
+
+ records.Count.ShouldBe(3);
+
+ var jobAName = BackgroundJobNameAttribute.GetName();
+ var jobBName = BackgroundJobNameAttribute.GetName();
+
+ var dedicated = records.Where(r => r.JobNameFilter?.Mode == BackgroundJobNameFilterMode.Include).ToList();
+ dedicated.Count.ShouldBe(2);
+ dedicated.ShouldContain(r => r.DistributedLockName == "lock-a" && r.JobNameFilter!.JobNames.Contains(jobAName));
+ dedicated.ShouldContain(r => r.DistributedLockName == "lock-b" && r.JobNameFilter!.JobNames.Contains(jobBName));
+
+ var defaultWorker = records.Single(r => r.JobNameFilter?.Mode == BackgroundJobNameFilterMode.Exclude);
+ defaultWorker.DistributedLockName.ShouldBeNull();
+ defaultWorker.JobNameFilter!.JobNames.ShouldContain(jobAName);
+ defaultWorker.JobNameFilter!.JobNames.ShouldContain(jobBName);
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_Tests.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_Tests.cs
new file mode 100644
index 0000000000..4d8d506f19
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_Tests.cs
@@ -0,0 +1,300 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Volo.Abp.BackgroundWorkers;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.DistributedLocking;
+using Volo.Abp.Threading;
+using Volo.Abp.Timing;
+using Xunit;
+
+// ReSharper disable PossibleMultipleEnumeration
+
+namespace Volo.Abp.BackgroundJobs;
+
+public class BackgroundJobWorker_Tests : BackgroundJobWorkerTestBase
+{
+ private readonly IBackgroundJobStore _store;
+ private readonly IClock _clock;
+ private readonly AbpBackgroundJobWorkerOptions _workerOptions;
+
+ public BackgroundJobWorker_Tests()
+ {
+ _store = GetRequiredService();
+ _clock = GetRequiredService();
+ _workerOptions = GetRequiredService>().Value;
+ }
+
+ private TestableBackgroundJobWorker CreateWorker()
+ {
+ return new TestableBackgroundJobWorker(
+ GetRequiredService(),
+ GetRequiredService>(),
+ GetRequiredService>(),
+ GetRequiredService(),
+ GetRequiredService());
+ }
+
+ private BackgroundJobInfo NewJob(string jobName)
+ {
+ return new BackgroundJobInfo
+ {
+ Id = Guid.NewGuid(),
+ JobName = jobName,
+ JobArgs = "{}",
+ CreationTime = _clock.Now,
+ NextTryTime = _clock.Now.AddMinutes(-1)
+ };
+ }
+
+ private PeriodicBackgroundWorkerContext Context()
+ {
+ return new PeriodicBackgroundWorkerContext(ServiceProvider);
+ }
+
+ // Storing successful jobs
+
+ [Fact]
+ public async Task Should_Keep_Successful_Job_As_History_When_Enabled()
+ {
+ _workerOptions.StoreSuccessfulJobs = true;
+
+ var jobInfo = NewJob("job-a");
+ await _store.InsertAsync(jobInfo);
+
+ await CreateWorker().HandleJobSuccessPublicAsync(_store, jobInfo, _clock);
+
+ // The job is kept (marked completed), not deleted.
+ var kept = await _store.FindAsync(jobInfo.Id);
+ kept.ShouldNotBeNull();
+ kept.CompletionTime.ShouldNotBeNull();
+
+ // ...but it is excluded from the waiting jobs.
+ (await _store.GetWaitingJobsAsync(null, 1000)).ShouldNotContain(j => j.Id == jobInfo.Id);
+ }
+
+ [Fact]
+ public async Task Should_Delete_Successful_Job_When_Disabled()
+ {
+ // StoreSuccessfulJobs is false by default.
+ var jobInfo = NewJob("job-a");
+ await _store.InsertAsync(jobInfo);
+
+ await CreateWorker().HandleJobSuccessPublicAsync(_store, jobInfo, _clock);
+
+ (await _store.FindAsync(jobInfo.Id)).ShouldBeNull();
+ }
+
+ // Dedicated workers by job name
+
+ [Fact]
+ public async Task Should_Return_Only_Included_Jobs()
+ {
+ await _store.InsertAsync(NewJob("job-a"));
+ await _store.InsertAsync(NewJob("job-a"));
+ await _store.InsertAsync(NewJob("job-b"));
+
+ var worker = CreateWorker();
+ worker.ConfigureTest(BackgroundJobNameFilter.Include(new[] { "job-a" }));
+
+ var jobs = await worker.GetWaitingJobsPublicAsync(Context(), _store);
+
+ jobs.Count.ShouldBe(2);
+ jobs.ShouldAllBe(j => j.JobName == "job-a");
+ }
+
+ [Fact]
+ public async Task Should_Exclude_Given_Jobs()
+ {
+ await _store.InsertAsync(NewJob("job-a"));
+ await _store.InsertAsync(NewJob("job-b"));
+
+ var worker = CreateWorker();
+ worker.ConfigureTest(BackgroundJobNameFilter.Exclude(new[] { "job-a" }));
+
+ var jobs = await worker.GetWaitingJobsPublicAsync(Context(), _store);
+
+ jobs.Count.ShouldBe(1);
+ jobs.Single().JobName.ShouldBe("job-b");
+ }
+
+ [Fact]
+ public void Should_Match_Job_Names_By_Filter()
+ {
+ BackgroundJobNameFilter.None.IsMatch("any").ShouldBeTrue();
+
+ var include = BackgroundJobNameFilter.Include(new[] { "job-a" });
+ include.IsMatch("job-a").ShouldBeTrue();
+ include.IsMatch("job-b").ShouldBeFalse();
+
+ var exclude = BackgroundJobNameFilter.Exclude(new[] { "job-a" });
+ exclude.IsMatch("job-a").ShouldBeFalse();
+ exclude.IsMatch("job-b").ShouldBeTrue();
+ }
+
+ [Fact]
+ public void BackgroundJobNameFilter_Should_Reject_Invalid_Mode_And_Names_Combinations()
+ {
+ Should.Throw(() => new BackgroundJobNameFilter(BackgroundJobNameFilterMode.Include));
+ Should.Throw(() => new BackgroundJobNameFilter(BackgroundJobNameFilterMode.None, new[] { "job-a" }));
+ Should.Throw(() => new BackgroundJobNameFilter((BackgroundJobNameFilterMode)99, new[] { "job-a" }));
+ }
+
+ // Parallel execution / eligibility
+
+ [Fact]
+ public void Should_Evaluate_Job_Eligibility()
+ {
+ var worker = CreateWorker();
+
+ worker.IsJobEligiblePublic(null, _clock).ShouldBeFalse();
+
+ var future = NewJob("job-a");
+ future.NextTryTime = _clock.Now.AddMinutes(5);
+ worker.IsJobEligiblePublic(future, _clock).ShouldBeFalse();
+
+ var abandoned = NewJob("job-a");
+ abandoned.IsAbandoned = true;
+ worker.IsJobEligiblePublic(abandoned, _clock).ShouldBeFalse();
+
+ var completed = NewJob("job-a");
+ completed.CompletionTime = _clock.Now;
+ worker.IsJobEligiblePublic(completed, _clock).ShouldBeFalse();
+
+ var eligible = NewJob("job-a");
+ worker.IsJobEligiblePublic(eligible, _clock).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Should_Not_Be_Eligible_When_Job_Name_Filtered_Out()
+ {
+ var worker = CreateWorker();
+ worker.ConfigureTest(BackgroundJobNameFilter.Include(new[] { "job-a" }));
+
+ var otherJob = NewJob("job-b");
+ worker.IsJobEligiblePublic(otherJob, _clock).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Should_Require_Job_Args_Types_For_A_Dedicated_Worker()
+ {
+ Should.Throw(() => new BackgroundJobWorkerConfiguration("lock-a"));
+ }
+
+ [Fact]
+ public void AddDedicatedWorker_Should_Throw_At_Registration_When_A_Job_Type_Is_Added_Twice()
+ {
+ var options = new AbpBackgroundJobWorkerOptions();
+ options.AddDedicatedWorker("lock-a");
+
+ Should.Throw(() => options.AddDedicatedWorker("lock-b"));
+ }
+
+ [Fact]
+ public void AddDedicatedWorker_Should_Throw_At_Registration_When_A_Lock_Name_Is_Reused()
+ {
+ var options = new AbpBackgroundJobWorkerOptions();
+ options.AddDedicatedWorker("dup-lock");
+
+ Should.Throw(() => options.AddDedicatedWorker("dup-lock"));
+ }
+
+ [Fact]
+ public void AddDedicatedWorker_Should_Throw_At_Registration_When_Lock_Name_Equals_The_Default()
+ {
+ var options = new AbpBackgroundJobWorkerOptions();
+
+ Should.Throw(() => options.AddDedicatedWorker(options.DistributedLockName));
+ }
+
+ // Parallel execution
+
+ [Fact]
+ public async Task Should_Execute_Multiple_Jobs_In_Parallel()
+ {
+ _workerOptions.MaxParallelJobExecutionCount = 3;
+
+ var jobManager = GetRequiredService();
+ await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = "1" });
+ await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = "2" });
+ await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = "3" });
+
+ await CreateWorker().ExecuteJobsInParallelPublicAsync(Context());
+
+ var tracker = GetRequiredService();
+ tracker.Executed.Count.ShouldBe(3);
+ tracker.Executed.ShouldContain("1");
+ tracker.Executed.ShouldContain("2");
+ tracker.Executed.ShouldContain("3");
+
+ // Each job must run in its own service scope (isolated DbContext/UOW).
+ tracker.ScopeIds.Distinct().Count().ShouldBe(3);
+
+ (await _store.GetWaitingJobsAsync(null, 1000)).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Should_Execute_At_Most_MaxParallel_Jobs_Per_Cycle()
+ {
+ _workerOptions.MaxParallelJobExecutionCount = 2;
+
+ var jobManager = GetRequiredService();
+ for (var i = 0; i < 5; i++)
+ {
+ await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = i.ToString() });
+ }
+
+ await CreateWorker().ExecuteJobsInParallelPublicAsync(Context());
+
+ var tracker = GetRequiredService();
+ tracker.Executed.Count.ShouldBe(2);
+ (await _store.GetWaitingJobsAsync(null, 1000)).Count.ShouldBe(3);
+ }
+
+ [Fact]
+ public async Task Should_Skip_Job_Already_Claimed_By_Another_Instance()
+ {
+ _workerOptions.MaxParallelJobExecutionCount = 5;
+
+ var jobManager = GetRequiredService();
+ var lockedJobId = Guid.Parse(await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = "locked" }));
+ await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = "free" });
+
+ var distributedLock = GetRequiredService();
+ var lockName = _workerOptions.PerJobDistributedLockPrefix + lockedJobId;
+
+ await using (await distributedLock.TryAcquireAsync(lockName))
+ {
+ await CreateWorker().ExecuteJobsInParallelPublicAsync(Context());
+ }
+
+ var tracker = GetRequiredService();
+ tracker.Executed.ShouldContain("free");
+ tracker.Executed.ShouldNotContain("locked");
+ (await _store.FindAsync(lockedJobId)).ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task Should_Mark_Job_Completed_On_Successful_Execution_When_Storing_Enabled()
+ {
+ _workerOptions.StoreSuccessfulJobs = true;
+ _workerOptions.MaxParallelJobExecutionCount = 2;
+
+ var jobManager = GetRequiredService();
+ var jobId = Guid.Parse(await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = "1" }));
+
+ await CreateWorker().ExecuteJobsInParallelPublicAsync(Context());
+
+ GetRequiredService().Executed.ShouldContain("1");
+
+ // The job is kept (marked completed), not deleted, and excluded from the waiting list.
+ var job = await _store.FindAsync(jobId);
+ job.ShouldNotBeNull();
+ job.CompletionTime.ShouldNotBeNull();
+ (await _store.GetWaitingJobsAsync(null, 1000)).ShouldNotContain(j => j.Id == jobId);
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/RecordingBackgroundJobWorker.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/RecordingBackgroundJobWorker.cs
new file mode 100644
index 0000000000..6acb5ca78d
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/RecordingBackgroundJobWorker.cs
@@ -0,0 +1,51 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Volo.Abp.DependencyInjection;
+
+namespace Volo.Abp.BackgroundJobs;
+
+///
+/// Records every call so multi-worker registration
+/// (resolved from DI by ) can be asserted. Does not start any timer.
+///
+public class WorkerStartRecorder
+{
+ public List Records { get; } = new List();
+}
+
+public class WorkerStartRecord
+{
+ public string? DistributedLockName { get; set; }
+
+ public BackgroundJobNameFilter? JobNameFilter { get; set; }
+}
+
+public class RecordingBackgroundJobWorker : IBackgroundJobWorker, ITransientDependency
+{
+ private readonly WorkerStartRecorder _recorder;
+
+ public RecordingBackgroundJobWorker(WorkerStartRecorder recorder)
+ {
+ _recorder = recorder;
+ }
+
+ public Task StartAsync(
+ string? distributedLockName = null,
+ BackgroundJobNameFilter? jobNameFilter = null,
+ CancellationToken cancellationToken = default)
+ {
+ _recorder.Records.Add(new WorkerStartRecord
+ {
+ DistributedLockName = distributedLockName,
+ JobNameFilter = jobNameFilter
+ });
+
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.CompletedTask;
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/TestableBackgroundJobCleanupWorker.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/TestableBackgroundJobCleanupWorker.cs
new file mode 100644
index 0000000000..7c6cc16dec
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/TestableBackgroundJobCleanupWorker.cs
@@ -0,0 +1,26 @@
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Volo.Abp.BackgroundWorkers;
+using Volo.Abp.DistributedLocking;
+using Volo.Abp.Threading;
+
+namespace Volo.Abp.BackgroundJobs;
+
+public class TestableBackgroundJobCleanupWorker : BackgroundJobCleanupWorker
+{
+ public TestableBackgroundJobCleanupWorker(
+ AbpAsyncTimer timer,
+ IServiceScopeFactory serviceScopeFactory,
+ IOptions jobOptions,
+ IOptions workerOptions,
+ IAbpDistributedLock distributedLock)
+ : base(timer, serviceScopeFactory, jobOptions, workerOptions, distributedLock)
+ {
+ }
+
+ public Task DoWorkPublicAsync(PeriodicBackgroundWorkerContext workerContext)
+ {
+ return DoWorkAsync(workerContext);
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/TestableBackgroundJobWorker.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/TestableBackgroundJobWorker.cs
new file mode 100644
index 0000000000..27274320fb
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/TestableBackgroundJobWorker.cs
@@ -0,0 +1,55 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Volo.Abp.BackgroundWorkers;
+using Volo.Abp.DistributedLocking;
+using Volo.Abp.Threading;
+using Volo.Abp.Timing;
+
+namespace Volo.Abp.BackgroundJobs;
+
+///
+/// Exposes the protected members of for unit testing.
+///
+public class TestableBackgroundJobWorker : BackgroundJobWorker
+{
+ public TestableBackgroundJobWorker(
+ AbpAsyncTimer timer,
+ IOptions jobOptions,
+ IOptions workerOptions,
+ IServiceScopeFactory serviceScopeFactory,
+ IAbpDistributedLock distributedLock)
+ : base(timer, jobOptions, workerOptions, serviceScopeFactory, distributedLock)
+ {
+ }
+
+ public void ConfigureTest(
+ BackgroundJobNameFilter? jobNameFilter = null,
+ string? distributedLockName = null)
+ {
+ JobNameFilter = jobNameFilter ?? BackgroundJobNameFilter.None;
+ DistributedLockName = distributedLockName ?? WorkerOptions.DistributedLockName;
+ }
+
+ public Task HandleJobSuccessPublicAsync(IBackgroundJobStore store, BackgroundJobInfo jobInfo, IClock clock)
+ {
+ return HandleJobSuccessAsync(store, jobInfo, clock);
+ }
+
+ public Task ExecuteJobsInParallelPublicAsync(PeriodicBackgroundWorkerContext workerContext)
+ {
+ return ExecuteJobsInParallelAsync(workerContext);
+ }
+
+ public Task> GetWaitingJobsPublicAsync(PeriodicBackgroundWorkerContext workerContext, IBackgroundJobStore store)
+ {
+ return GetWaitingJobsAsync(workerContext, store);
+ }
+
+ public bool IsJobEligiblePublic(BackgroundJobInfo? jobInfo, IClock clock)
+ {
+ return IsJobEligible(jobInfo, clock);
+ }
+}
diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/WorkerTestJobs.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/WorkerTestJobs.cs
new file mode 100644
index 0000000000..dd9feec657
--- /dev/null
+++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/WorkerTestJobs.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Collections.Concurrent;
+using System.Threading.Tasks;
+using Volo.Abp.DependencyInjection;
+
+namespace Volo.Abp.BackgroundJobs;
+
+public class ParallelJobTracker
+{
+ public ConcurrentBag Executed { get; } = new ConcurrentBag();
+
+ public ConcurrentBag ScopeIds { get; } = new ConcurrentBag();
+}
+
+///
+/// Scoped service used to verify that each parallel job runs in its own service scope.
+///
+public class ScopeMarker
+{
+ public Guid Id { get; } = Guid.NewGuid();
+}
+
+public class ParallelTestJobArgs
+{
+ public string Value { get; set; } = default!;
+}
+
+public class ParallelTestJob : AsyncBackgroundJob, ITransientDependency
+{
+ private readonly ParallelJobTracker _tracker;
+ private readonly ScopeMarker _scopeMarker;
+
+ public ParallelTestJob(ParallelJobTracker tracker, ScopeMarker scopeMarker)
+ {
+ _tracker = tracker;
+ _scopeMarker = scopeMarker;
+ }
+
+ public override Task ExecuteAsync(ParallelTestJobArgs args)
+ {
+ _tracker.Executed.Add(args.Value);
+ _tracker.ScopeIds.Add(_scopeMarker.Id);
+ return Task.CompletedTask;
+ }
+}
+
+public class WorkerJobAArgs
+{
+ public string Value { get; set; } = default!;
+}
+
+public class WorkerJobA : AsyncBackgroundJob, ITransientDependency
+{
+ public override Task ExecuteAsync(WorkerJobAArgs args)
+ {
+ return Task.CompletedTask;
+ }
+}
+
+public class WorkerJobBArgs
+{
+ public string Value { get; set; } = default!;
+}
+
+public class WorkerJobB : AsyncBackgroundJob, ITransientDependency
+{
+ public override Task ExecuteAsync(WorkerJobBArgs args)
+ {
+ return Task.CompletedTask;
+ }
+}
+
+// Two different args types that resolve to the same job name, to exercise the manager's
+// backstop validation (eager AddDedicatedWorker validation compares by type, not resolved name).
+[BackgroundJobName("shared-job-name")]
+public class SharedNameJobAArgs
+{
+}
+
+[BackgroundJobName("shared-job-name")]
+public class SharedNameJobBArgs
+{
+}
+
+public class SharedNameJobA : AsyncBackgroundJob, ITransientDependency
+{
+ public override Task ExecuteAsync(SharedNameJobAArgs args)
+ {
+ return Task.CompletedTask;
+ }
+}
+
+public class SharedNameJobB : AsyncBackgroundJob, ITransientDependency
+{
+ public override Task ExecuteAsync(SharedNameJobBArgs args)
+ {
+ return Task.CompletedTask;
+ }
+}
diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs
index fdf279e1ce..06965e2f0b 100644
--- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs
+++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs
@@ -43,6 +43,8 @@ public interface IRegularTestController
Task PostObjectWithQueryAsync(Car bodyValue);
+ Task QueryObjectWithBodyAsync(Car bodyValue);
+
Task GetObjectWithUrlAsync(Car bodyValue);
Task GetObjectandIdAsync(int id, Car bodyValue);
diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs
index 1d0864991b..5a86077126 100644
--- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs
+++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs
@@ -150,6 +150,13 @@ public class RegularTestController : AbpController, IRegularTestController
return Task.FromResult(bodyValue);
}
+ [AcceptVerbs("QUERY")]
+ [Route("query-object-with-body")]
+ public Task QueryObjectWithBodyAsync([FromBody] Car bodyValue)
+ {
+ return Task.FromResult(bodyValue);
+ }
+
[HttpGet]
[Route("post-object-with-url/bodyValue")]
public Task GetObjectWithUrlAsync(Car bodyValue)
diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_Tests.cs
index 01ec97d734..2e1af7ada0 100644
--- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_Tests.cs
+++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_Tests.cs
@@ -97,6 +97,14 @@ public class RegularTestControllerClientProxy_Tests : AbpHttpClientTestBase
result.Model.ShouldBe("Ford");
}
+ [Fact]
+ public async Task QueryObjectWithBodyAsync()
+ {
+ var result = await _controller.QueryObjectWithBodyAsync(new Car { Year = 1976, Model = "Ford", FirstReleaseDate = new DateTime(1976, 02, 22, 15, 0, 6, 22) });
+ result.Year.ShouldBe(1976);
+ result.Model.ShouldBe("Ford");
+ }
+
[Fact]
public async Task PostObjectWithQueryAsync_With_Different_Culture()
{
diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/HttpMethodHelper_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/HttpMethodHelper_Tests.cs
new file mode 100644
index 0000000000..ab6376c727
--- /dev/null
+++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/HttpMethodHelper_Tests.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Collections.Generic;
+using Shouldly;
+using Xunit;
+
+namespace Volo.Abp.Http;
+
+public class HttpMethodHelper_Tests
+{
+ private static readonly Dictionary> Predicates = new()
+ {
+ [HttpMethodHelper.Get] = HttpMethodHelper.IsGet,
+ [HttpMethodHelper.Post] = HttpMethodHelper.IsPost,
+ [HttpMethodHelper.Put] = HttpMethodHelper.IsPut,
+ [HttpMethodHelper.Delete] = HttpMethodHelper.IsDelete,
+ [HttpMethodHelper.Patch] = HttpMethodHelper.IsPatch,
+ [HttpMethodHelper.Head] = HttpMethodHelper.IsHead,
+ [HttpMethodHelper.Options] = HttpMethodHelper.IsOptions,
+ [HttpMethodHelper.Trace] = HttpMethodHelper.IsTrace,
+ [HttpMethodHelper.Query] = HttpMethodHelper.IsQuery
+ };
+
+ [Theory]
+ [InlineData(HttpMethodHelper.Get)]
+ [InlineData(HttpMethodHelper.Post)]
+ [InlineData(HttpMethodHelper.Put)]
+ [InlineData(HttpMethodHelper.Delete)]
+ [InlineData(HttpMethodHelper.Patch)]
+ [InlineData(HttpMethodHelper.Head)]
+ [InlineData(HttpMethodHelper.Options)]
+ [InlineData(HttpMethodHelper.Trace)]
+ [InlineData(HttpMethodHelper.Query)]
+ public void Is_Predicates_Should_Match_Only_Their_Own_Verb_Ignoring_Case(string verb)
+ {
+ foreach (var (name, predicate) in Predicates)
+ {
+ var shouldMatch = name == verb;
+ predicate(verb).ShouldBe(shouldMatch);
+ predicate(verb.ToLowerInvariant()).ShouldBe(shouldMatch);
+ }
+ }
+
+ [Fact]
+ public void Is_Predicates_Should_Return_False_For_Null()
+ {
+ foreach (var predicate in Predicates.Values)
+ {
+ predicate(null).ShouldBeFalse();
+ }
+ }
+
+ [Theory]
+ [InlineData("QUERY", true)]
+ [InlineData("query", true)]
+ [InlineData("Query", true)]
+ [InlineData("GET", false)]
+ [InlineData("POST", false)]
+ [InlineData("", false)]
+ [InlineData(null, false)]
+ public void IsQuery_Should_Match_Query_Method_Ignoring_Case(string? httpMethod, bool expected)
+ {
+ HttpMethodHelper.IsQuery(httpMethod).ShouldBe(expected);
+ }
+
+ [Fact]
+ public void ConvertToHttpMethod_Should_Support_Query()
+ {
+ HttpMethodHelper.ConvertToHttpMethod("QUERY").Method.ShouldBe("QUERY");
+ HttpMethodHelper.ConvertToHttpMethod("query").Method.ShouldBe("QUERY");
+ }
+
+ [Theory]
+ [InlineData("GET")]
+ [InlineData("POST")]
+ [InlineData("PUT")]
+ [InlineData("DELETE")]
+ [InlineData("PATCH")]
+ [InlineData("QUERY")]
+ public void ConvertToHttpMethod_Should_Not_Throw_For_Known_Methods(string httpMethod)
+ {
+ Should.NotThrow(() => HttpMethodHelper.ConvertToHttpMethod(httpMethod));
+ }
+
+ [Fact]
+ public void ConvertToHttpMethod_Should_Throw_For_Unknown_Method()
+ {
+ Should.Throw(() => HttpMethodHelper.ConvertToHttpMethod("UNKNOWN"));
+ }
+
+ [Theory]
+ [InlineData("GetFooAsync", "GET")]
+ [InlineData("GetListAsync", "GET")]
+ [InlineData("CreateFooAsync", "POST")]
+ [InlineData("UpdateFooAsync", "PUT")]
+ [InlineData("DeleteFooAsync", "DELETE")]
+ [InlineData("PatchFooAsync", "PATCH")]
+ [InlineData("DoSomethingAsync", "POST")]
+ // QUERY is intentionally NOT a naming convention: an action must opt in explicitly
+ // with [AcceptVerbs("QUERY")]. A method named Query* still maps to the default verb.
+ [InlineData("QueryFooAsync", "POST")]
+ public void GetConventionalVerbForMethodName_Should_Not_Map_Query_By_Name(string methodName, string expectedVerb)
+ {
+ HttpMethodHelper.GetConventionalVerbForMethodName(methodName).ShouldBe(expectedVerb);
+ }
+}
diff --git a/framework/test/Volo.Abp.Security.Tests/Volo/Abp/Security/Claims/CurrentPrincipalAccessor_Tests.cs b/framework/test/Volo.Abp.Security.Tests/Volo/Abp/Security/Claims/CurrentPrincipalAccessor_Tests.cs
index bbc18089be..6480af9c74 100644
--- a/framework/test/Volo.Abp.Security.Tests/Volo/Abp/Security/Claims/CurrentPrincipalAccessor_Tests.cs
+++ b/framework/test/Volo.Abp.Security.Tests/Volo/Abp/Security/Claims/CurrentPrincipalAccessor_Tests.cs
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Security.Claims;
+using System.Threading;
using Shouldly;
using Volo.Abp.Testing;
using Xunit;
@@ -30,20 +31,71 @@ public class CurrentPrincipalAccessor_Tests : AbpIntegratedTest
+ {
+ new Claim(ClaimTypes.NameIdentifier, "123456")
+ }));
+
+ using (accessor.Change(changedPrincipal))
+ {
+ accessor.Principal.ShouldBe(changedPrincipal);
+ }
+
+ // Disposing the Change scope must not pin the accessor to the fallback principal;
+ // a principal that becomes available afterwards has to be reflected.
+ var sourcePrincipal = new ClaimsPrincipal(new ClaimsIdentity(new List
+ {
+ new Claim(ClaimTypes.NameIdentifier, "654321")
+ }));
+ accessor.SourcePrincipal = sourcePrincipal;
+
+ accessor.Principal.ShouldBe(sourcePrincipal);
+ }
+
+ private class TestCurrentPrincipalAccessor : CurrentPrincipalAccessorBase
+ {
+ public ClaimsPrincipal? SourcePrincipal { get; set; }
+
+ protected override ClaimsPrincipal GetClaimsPrincipal()
+ {
+ return SourcePrincipal ?? new ClaimsPrincipal(new ClaimsIdentity());
}
- _currentPrincipalAccessor.Principal.ShouldBeNull();
}
}
diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/DemoAppModule.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/DemoAppModule.cs
index 976705b07d..5569f6800a 100644
--- a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/DemoAppModule.cs
+++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/DemoAppModule.cs
@@ -1,5 +1,7 @@
-using System.Threading.Tasks;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.Autofac;
+using Volo.Abp.BackgroundJobs.DemoApp.Jobs;
using Volo.Abp.BackgroundJobs.DemoApp.Shared;
using Volo.Abp.BackgroundJobs.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;
@@ -32,17 +34,31 @@ public class DemoAppModule : AbpModule
options.JobPollPeriod = 1000;
options.DefaultFirstWaitDuration = 1;
options.DefaultWaitFactor = 1;
+
+ // Keep every successfully completed job as history (marks CompletionTime instead of deleting).
+ // Completed jobs are excluded from the waiting query and pruned after SuccessfulJobRetentionTime.
+ options.StoreSuccessfulJobs = true;
+ options.SuccessfulJobRetentionTime = System.TimeSpan.FromDays(1);
+
+ // A dedicated worker (with its own distributed lock "DemoFeesWorkerLock") that only processes
+ // the slow fee-calculation jobs, so they don't block other jobs. A default worker is added automatically
+ // and processes all the remaining job types (e.g. SendEmailJob).
+ options.AddDedicatedWorker("DemoFeesWorkerLock");
+
+ // Let each worker execute up to 4 jobs in parallel (each job claimed with its own distributed lock,
+ // so multiple application instances can execute different jobs concurrently).
+ options.MaxParallelJobExecutionCount = 4;
});
}
- public override Task OnApplicationInitializationAsync(ApplicationInitializationContext context)
+ public override async Task OnApplicationInitializationAsync(ApplicationInitializationContext context)
{
- //TODO: Configure console logging
- //context
- // .ServiceProvider
- // .GetRequiredService()
- // .AddConsole(LogLevel.Debug);
+ // Enqueue a few demo jobs. The fee-calculation jobs are handled by the dedicated worker,
+ // while SendEmailJob is handled by the default worker.
+ var backgroundJobManager = context.ServiceProvider.GetRequiredService();
- return Task.CompletedTask;
+ await backgroundJobManager.EnqueueAsync(new CalculateAwsFeesJobArgs { AccountId = "acc-1" });
+ await backgroundJobManager.EnqueueAsync(new CalculateAzureFeesJobArgs { SubscriptionId = "sub-1" });
+ await backgroundJobManager.EnqueueAsync(new SendEmailJobArgs { To = "user@example.com", Subject = "Welcome" });
}
}
diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/CalculateAwsFeesJob.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/CalculateAwsFeesJob.cs
new file mode 100644
index 0000000000..2262bbc68d
--- /dev/null
+++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/CalculateAwsFeesJob.cs
@@ -0,0 +1,20 @@
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Volo.Abp.DependencyInjection;
+
+namespace Volo.Abp.BackgroundJobs.DemoApp.Jobs;
+
+public class CalculateAwsFeesJobArgs
+{
+ public string AccountId { get; set; } = default!;
+}
+
+public class CalculateAwsFeesJob : AsyncBackgroundJob, ITransientDependency
+{
+ public override Task ExecuteAsync(CalculateAwsFeesJobArgs args)
+ {
+ // A slow, resource-intensive job that is isolated on a dedicated worker (see DemoAppModule).
+ Logger.LogInformation($"[AWS fees] Calculating fees for account '{args.AccountId}'...");
+ return Task.CompletedTask;
+ }
+}
diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/CalculateAzureFeesJob.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/CalculateAzureFeesJob.cs
new file mode 100644
index 0000000000..d0dc43cd94
--- /dev/null
+++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/CalculateAzureFeesJob.cs
@@ -0,0 +1,20 @@
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Volo.Abp.DependencyInjection;
+
+namespace Volo.Abp.BackgroundJobs.DemoApp.Jobs;
+
+public class CalculateAzureFeesJobArgs
+{
+ public string SubscriptionId { get; set; } = default!;
+}
+
+public class CalculateAzureFeesJob : AsyncBackgroundJob, ITransientDependency
+{
+ public override Task ExecuteAsync(CalculateAzureFeesJobArgs args)
+ {
+ // Isolated on the same dedicated fees worker as CalculateAwsFeesJob.
+ Logger.LogInformation($"[Azure fees] Calculating fees for subscription '{args.SubscriptionId}'...");
+ return Task.CompletedTask;
+ }
+}
diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/SendEmailJob.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/SendEmailJob.cs
new file mode 100644
index 0000000000..846b06f4fe
--- /dev/null
+++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/SendEmailJob.cs
@@ -0,0 +1,23 @@
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Volo.Abp.DependencyInjection;
+
+namespace Volo.Abp.BackgroundJobs.DemoApp.Jobs;
+
+public class SendEmailJobArgs
+{
+ public string To { get; set; } = default!;
+
+ public string Subject { get; set; } = default!;
+}
+
+public class SendEmailJob : AsyncBackgroundJob, ITransientDependency
+{
+ public override Task ExecuteAsync(SendEmailJobArgs args)
+ {
+ // A fast job. It is NOT configured for a dedicated worker, so the default worker processes it
+ // without waiting behind the slow fee-calculation jobs.
+ Logger.LogInformation($"[Email] Sending '{args.Subject}' to '{args.To}'...");
+ return Task.CompletedTask;
+ }
+}
diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260701082002_Added_CompletionTime_To_BackgroundJobs.Designer.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260701082002_Added_CompletionTime_To_BackgroundJobs.Designer.cs
new file mode 100644
index 0000000000..01241ef94a
--- /dev/null
+++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260701082002_Added_CompletionTime_To_BackgroundJobs.Designer.cs
@@ -0,0 +1,99 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Volo.Abp.BackgroundJobs.DemoApp.Db;
+using Volo.Abp.EntityFrameworkCore;
+
+#nullable disable
+
+namespace Volo.Abp.BackgroundJobs.DemoApp.Migrations
+{
+ [DbContext(typeof(DemoAppDbContext))]
+ [Migration("20260701082002_Added_CompletionTime_To_BackgroundJobs")]
+ partial class Added_CompletionTime_To_BackgroundJobs
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.SqlServer)
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Volo.Abp.BackgroundJobs.BackgroundJobRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApplicationName")
+ .HasMaxLength(96)
+ .HasColumnType("nvarchar(96)");
+
+ b.Property("CompletionTime")
+ .HasColumnType("datetime2");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("nvarchar(40)")
+ .HasColumnName("ConcurrencyStamp");
+
+ b.Property("CreationTime")
+ .HasColumnType("datetime2")
+ .HasColumnName("CreationTime");
+
+ b.Property("ExtraProperties")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("IsAbandoned")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("JobArgs")
+ .IsRequired()
+ .HasMaxLength(1048576)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("JobName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("LastTryTime")
+ .HasColumnType("datetime2");
+
+ b.Property("NextTryTime")
+ .HasColumnType("datetime2");
+
+ b.Property("Priority")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint")
+ .HasDefaultValue((byte)15);
+
+ b.Property("TryCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("smallint")
+ .HasDefaultValue((short)0);
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApplicationName", "CompletionTime", "IsAbandoned", "NextTryTime");
+
+ b.ToTable("AbpBackgroundJobs", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260701082002_Added_CompletionTime_To_BackgroundJobs.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260701082002_Added_CompletionTime_To_BackgroundJobs.cs
new file mode 100644
index 0000000000..96f35f3379
--- /dev/null
+++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260701082002_Added_CompletionTime_To_BackgroundJobs.cs
@@ -0,0 +1,47 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Volo.Abp.BackgroundJobs.DemoApp.Migrations
+{
+ ///
+ public partial class Added_CompletionTime_To_BackgroundJobs : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_AbpBackgroundJobs_IsAbandoned_NextTryTime",
+ table: "AbpBackgroundJobs");
+
+ migrationBuilder.AddColumn(
+ name: "CompletionTime",
+ table: "AbpBackgroundJobs",
+ type: "datetime2",
+ nullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_AbpBackgroundJobs_ApplicationName_CompletionTime_IsAbandoned_NextTryTime",
+ table: "AbpBackgroundJobs",
+ columns: new[] { "ApplicationName", "CompletionTime", "IsAbandoned", "NextTryTime" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_AbpBackgroundJobs_ApplicationName_CompletionTime_IsAbandoned_NextTryTime",
+ table: "AbpBackgroundJobs");
+
+ migrationBuilder.DropColumn(
+ name: "CompletionTime",
+ table: "AbpBackgroundJobs");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_AbpBackgroundJobs_IsAbandoned_NextTryTime",
+ table: "AbpBackgroundJobs",
+ columns: new[] { "IsAbandoned", "NextTryTime" });
+ }
+ }
+}
diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/DemoAppDbContextModelSnapshot.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/DemoAppDbContextModelSnapshot.cs
index ab91bc354c..af43eb368f 100644
--- a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/DemoAppDbContextModelSnapshot.cs
+++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/DemoAppDbContextModelSnapshot.cs
@@ -19,7 +19,7 @@ namespace Volo.Abp.BackgroundJobs.DemoApp.Migrations
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.SqlServer)
- .HasAnnotation("ProductVersion", "10.0.2")
+ .HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@@ -34,6 +34,9 @@ namespace Volo.Abp.BackgroundJobs.DemoApp.Migrations
.HasMaxLength(96)
.HasColumnType("nvarchar(96)");
+ b.Property("CompletionTime")
+ .HasColumnType("datetime2");
+
b.Property("ConcurrencyStamp")
.IsConcurrencyToken()
.IsRequired()
@@ -83,7 +86,7 @@ namespace Volo.Abp.BackgroundJobs.DemoApp.Migrations
b.HasKey("Id");
- b.HasIndex("IsAbandoned", "NextTryTime");
+ b.HasIndex("ApplicationName", "CompletionTime", "IsAbandoned", "NextTryTime");
b.ToTable("AbpBackgroundJobs", (string)null);
});
diff --git a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobRecord.cs b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobRecord.cs
index c01f933781..18862c8b1d 100644
--- a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobRecord.cs
+++ b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobRecord.cs
@@ -48,6 +48,12 @@ public class BackgroundJobRecord : AggregateRoot, IHasCreationTime
///
public virtual bool IsAbandoned { get; set; }
+ ///
+ /// The time this job was completed successfully. When set, the job is kept as history and excluded
+ /// from the waiting jobs query (set only when successful job persistence is enabled).
+ ///
+ public virtual DateTime? CompletionTime { get; set; }
+
///
/// Priority of this job.
///
diff --git a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobStore.cs b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobStore.cs
index c7dbd6fbef..9d24c6c236 100644
--- a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobStore.cs
+++ b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobStore.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
using Volo.Abp.ObjectMapping;
@@ -22,9 +23,13 @@ public class BackgroundJobStore : IBackgroundJobStore, ITransientDependency
public virtual async Task FindAsync(Guid jobId)
{
- return ObjectMapper.Map(
- await BackgroundJobRepository.FindAsync(jobId)
- );
+ var backgroundJobRecord = await BackgroundJobRepository.FindAsync(jobId);
+ if (backgroundJobRecord == null)
+ {
+ return null!;
+ }
+
+ return ObjectMapper.Map(backgroundJobRecord);
}
public virtual async Task InsertAsync(BackgroundJobInfo jobInfo)
@@ -34,18 +39,37 @@ public class BackgroundJobStore : IBackgroundJobStore, ITransientDependency
);
}
- public virtual async Task> GetWaitingJobsAsync(string applicationName, int maxResultCount)
+ public virtual async Task> GetWaitingJobsAsync(string? applicationName, int maxResultCount)
{
return ObjectMapper.Map, List>(
await BackgroundJobRepository.GetWaitingListAsync(applicationName, maxResultCount)
);
}
+ public virtual async Task> GetWaitingJobsAsync(
+ string? applicationName,
+ int maxResultCount,
+ BackgroundJobNameFilter? jobNameFilter)
+ {
+ return ObjectMapper.Map, List>(
+ await BackgroundJobRepository.GetWaitingListAsync(applicationName, maxResultCount, jobNameFilter)
+ );
+ }
+
public virtual async Task DeleteAsync(Guid jobId)
{
await BackgroundJobRepository.DeleteAsync(jobId);
}
+ public virtual async Task DeleteAsync(
+ string? applicationName,
+ DateTime completedBefore,
+ int maxResultCount,
+ CancellationToken cancellationToken = default)
+ {
+ return await BackgroundJobRepository.DeleteAsync(applicationName, completedBefore, maxResultCount, cancellationToken);
+ }
+
public virtual async Task UpdateAsync(BackgroundJobInfo jobInfo)
{
var backgroundJobRecord = await BackgroundJobRepository.FindAsync(jobInfo.Id);
diff --git a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobsDomainMapperlyMappers.cs b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobsDomainMapperlyMappers.cs
index 9ac5cef9bf..a605582201 100644
--- a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobsDomainMapperlyMappers.cs
+++ b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobsDomainMapperlyMappers.cs
@@ -5,7 +5,7 @@ using System.Threading.Tasks;
using Riok.Mapperly.Abstractions;
using Volo.Abp.Mapperly;
-namespace Volo.Abp.BackgroundJobs;
+namespace Volo.Abp.BackgroundJobs;
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
public partial class BackgroundJobInfoToBackgroundJobRecordMapper
@@ -31,6 +31,6 @@ public partial class BackgroundJobRecordToBackgroundJobInfoMapper
: MapperBase
{
public override partial BackgroundJobInfo Map(BackgroundJobRecord source);
-
+
public override partial void Map(BackgroundJobRecord source, BackgroundJobInfo destination);
}
diff --git a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/IBackgroundJobRepository.cs b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/IBackgroundJobRepository.cs
index 7e189df5c8..d4e8df2d83 100644
--- a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/IBackgroundJobRepository.cs
+++ b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/IBackgroundJobRepository.cs
@@ -2,12 +2,26 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
-using JetBrains.Annotations;
using Volo.Abp.Domain.Repositories;
namespace Volo.Abp.BackgroundJobs;
public interface IBackgroundJobRepository : IBasicRepository
{
- Task> GetWaitingListAsync([CanBeNull] string applicationName, int maxResultCount, CancellationToken cancellationToken = default);
+ Task> GetWaitingListAsync(
+ string? applicationName,
+ int maxResultCount,
+ CancellationToken cancellationToken = default);
+
+ Task> GetWaitingListAsync(
+ string? applicationName,
+ int maxResultCount,
+ BackgroundJobNameFilter? jobNameFilter,
+ CancellationToken cancellationToken = default);
+
+ Task DeleteAsync(
+ string? applicationName,
+ DateTime completedBefore,
+ int maxResultCount,
+ CancellationToken cancellationToken = default);
}
diff --git a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/BackgroundJobsDbContextModelCreatingExtensions.cs b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/BackgroundJobsDbContextModelCreatingExtensions.cs
index 53a2c53843..70a73a8562 100644
--- a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/BackgroundJobsDbContextModelCreatingExtensions.cs
+++ b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/BackgroundJobsDbContextModelCreatingExtensions.cs
@@ -29,9 +29,10 @@ public static class BackgroundJobsDbContextModelCreatingExtensions
b.Property(x => x.NextTryTime);
b.Property(x => x.LastTryTime);
b.Property(x => x.IsAbandoned).HasDefaultValue(false);
+ b.Property(x => x.CompletionTime);
b.Property(x => x.Priority).HasDefaultValue(BackgroundJobPriority.Normal).HasSentinel(BackgroundJobPriority.Normal);
- b.HasIndex(x => new { x.IsAbandoned, x.NextTryTime });
+ b.HasIndex(x => new { x.ApplicationName, x.CompletionTime, x.IsAbandoned, x.NextTryTime });
b.ApplyObjectExtensionMappings();
});
diff --git a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/EfCoreBackgroundJobRepository.cs b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/EfCoreBackgroundJobRepository.cs
index a81de3cdfb..5b55a7adf8 100644
--- a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/EfCoreBackgroundJobRepository.cs
+++ b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/EfCoreBackgroundJobRepository.cs
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
-using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using Volo.Abp.Domain.Repositories.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;
@@ -23,20 +22,71 @@ public class EfCoreBackgroundJobRepository : EfCoreRepository> GetWaitingListAsync([CanBeNull] string applicationName, int maxResultCount, CancellationToken cancellationToken = default)
+ public virtual Task