From 845a1010415784a4fc9ca94f047175f75137069d Mon Sep 17 00:00:00 2001 From: d040506 Date: Wed, 3 Jun 2026 07:57:13 +0200 Subject: [PATCH 1/6] Introduce the shared outbox and outbox collector strategies. --- java/outbox.md | 122 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/java/outbox.md b/java/outbox.md index 0cba54d407..1c651ab2fc 100644 --- a/java/outbox.md +++ b/java/outbox.md @@ -415,6 +415,128 @@ void handleAuditLogProcessingErrors(OutboxMessageEventContext context) { [Learn more about `EventContext.proceed()`.](./event-handlers/#proceed-on){.learn-more} + +## Shared Outbox { #shared-outbox} + +A shared outbox is a custom outbox configured with a dedicated persistence service. In this case, the outbox uses the assigned persistence service for all submits and lookups, independent from the current tenant context. This is useful in scenarios where outbox entries need to be stored and processed in a tenant-independent manner. + +To configure a shared outbox, first create an additional persistence service for the dedicated database. Then, assign it to a custom outbox using the `persistenceService` parameter. + +#### 1. Create an Additional Persistence Service + +Define a persistence service that points to your shared database binding: + +::: code-group +```yaml [srv/src/main/resources/application.yaml] +cds: + persistence.services: + my-shared-ps: + binding: "my-shared-hdi" +``` +::: + +[Learn more about additional Persistence Services.](./cqn-services/persistence-services#additional-persistence-services){.learn-more} + +#### 2. Configure the Shared Outbox + +Create a custom outbox and assign the persistence service using the `persistenceService` parameter: + +::: code-group +```yaml [srv/src/main/resources/application.yaml] +cds: + outbox: + services: + MySharedOutbox: + persistenceService: "my-shared-ps" +``` +::: + +With this configuration, the outbox `MySharedOutbox` uses the persistence service `my-shared-ps` for all submit and lookup operations. Since this persistence service points to a dedicated database, the outbox operates independently from the tenant context — outbox entries are stored and retrieved from the shared database regardless of which tenant triggers the processing. + + +## Outbox Collector Strategies { #outbox-collector-strategies} + +In a multitenant environment, outbox entries reside in tenant-specific persistences. The outbox collector is triggered when events are submitted to the outbox. However, if an application instance crashes, unprocessed outbox entries for a tenant are only retried when that tenant next produces a new outbox event. If after a crash, a tenant becomes inactive or has a long period until its next outbox submission, the remaining entries stay unprocessed until the tenant triggers a new event. + +To address this, CAP Java provides two scheduler-based strategies that periodically check tenant outboxes for unprocessed entries. Both strategies are disabled by default and must be explicitly enabled. + +::: tip Prerequisite +The outbox scheduler must be enabled for both strategies. Set `cds.outbox.persistent.scheduler.enabled` to `true`. +::: + +::: code-group +```yaml [srv/src/main/resources/application.yaml] +cds: + outbox: + persistent: + scheduler: + enabled: true +``` +::: + +### All-Tenants Task { #all-tenants-task} + +The all-tenants task periodically iterates over **all** tenant outboxes and triggers the collector for each tenant. It acts as a safety net to ensure no outbox entries are missed, regardless of tenant activity. + +::: code-group +```yaml [srv/src/main/resources/application.yaml] +cds: + outbox: + persistent: + scheduler: + enabled: true + allTenantsTask: + enabled: true + startDelay: PT30S + interval: PT2H + spreadTime: PT15M +``` +::: + +The configuration options are: + +- `startDelay` (default `PT30S`): Delay after application startup before the first execution. +- `interval` (default `PT2H`): Interval between successive executions. +- `spreadTime` (default `PT15M`): Time span over which individual tenant checks are randomly distributed. This avoids a thundering-herd effect where all tenant outboxes are checked simultaneously. + +::: warning Performance consideration +For applications with a large number of tenants, traversing all tenants can cause significant overhead due to tenant context switches. This may impact application performance. Consider the [Hot-Tenant Task](#hot-tenant-task) as a lighter alternative that only checks recently active tenants. +::: + +### Hot-Tenant Task { #hot-tenant-task} + +Instead of iterating over all tenants, the hot-tenant task tracks which tenants have been recently active and only triggers the outbox collector for those tenants. Lookups are well distributed over time to avoid activity jams. + +The hot-tenant task requires: + +1. The outbox scheduler must be enabled (`cds.outbox.persistent.scheduler.enabled: true`). +2. A [shared outbox](#shared-outbox) must be configured — the hot-tenant task uses the shared outbox to persist tenant activity records independently from the tenant context. + +::: code-group +```yaml [srv/src/main/resources/application.yaml] +cds: + outbox: + persistent: + scheduler: + enabled: true + hotTenantTask: + enabled: true + sharedOutbox: "MySharedOutbox" + maxTaskDelay: PT2H + services: + MySharedOutbox: + persistenceService: "my-shared-ps" +``` +::: + +The configuration options are: + +- `sharedOutbox`: The name of the shared outbox service used to store tenant activity records. This outbox must be configured with a dedicated persistence service as described in the [Shared Outbox](#shared-outbox) section. +- `maxTaskDelay` (default `PT2H`): The maximum time to wait after a tenant event before checking that tenant's outbox. Lookups are distributed within this window to spread the load evenly. + +[Learn more about configuring a Shared Outbox.](#shared-outbox){.learn-more} + + ## Outbox Dead Letter Queue The transactional outbox tries to process each entry a specific number of times. The number of attempts is configurable per outbox by setting the configuration `cds.outbox.services..maxAttempts`. From e594b6b7ce81a3cef5b758f73adc986c79244aae Mon Sep 17 00:00:00 2001 From: d040506 Date: Fri, 19 Jun 2026 14:45:50 +0200 Subject: [PATCH 2/6] Introduced the Scheduling API, updated the persistent outbox configuration, and updated the hot tenant task configuration. --- java/outbox.md | 468 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 402 insertions(+), 66 deletions(-) diff --git a/java/outbox.md b/java/outbox.md index 1c651ab2fc..3fe9cf4b72 100644 --- a/java/outbox.md +++ b/java/outbox.md @@ -34,56 +34,33 @@ Once the transaction succeeds, the messages are read from the database table and - If an emit was successful, the respective message is deleted from the database table. - If an emit wasn't successful, there will be a retry after some (exponentially growing) waiting time. After a maximum number of attempts, the message is ignored for processing and remains in the database table. Even if the app crashes the messages can be redelivered after successful application startup. -To enable the persistence for the outbox, you need to add the service `outbox` of kind `persistent-outbox` to the `cds.requires` section in the _package.json_ or _cdsrc.json_, which will automatically enhance your CDS model in order to support the persistent outbox. - -```jsonc -{ - // ... - "cds": { - "requires": { - "outbox": { - "kind": "persistent-outbox" - } - } - } -} -``` - -::: warning -Be aware that you need to migrate the database schemas of all tenants after you've enhanced your model with an outbox version from `@sap/cds` version 6.0.0 or later. -::: - -For a multitenancy scenario, make sure that the required configuration is also done in the MTX sidecar service. Make sure that the base model in all tenants is updated to activate the outbox. - -::: info Option: Add outbox to your base model -Alternatively, you can add `using from '@sap/cds/srv/outbox';` to your base model. In this case, you need to update the tenant models after deployment but you don't need to update MTX Sidecar. -::: -If enabled, CAP Java provides two persistent outbox services by default: +CAP Java provides the persistent outbox service `DefaultOutboxUnordered` by default. It is used by the [AuditLog service](../java/auditlog) and registered as the primary Spring bean for `OutboxService`. You can inject it directly without a qualifier: -- `DefaultOutboxOrdered` - is used by default by [messaging services](../java/messaging) -- `DefaultOutboxUnordered` - is used by default by the [AuditLog service](../java/auditlog) +```java +@Autowired +private OutboxService outboxService; +``` -The default configuration for both outboxes can be overridden using the `cds.outbox.services` section, for example in the _application.yaml_: +The default configuration can be overridden using the `cds.outbox.services` section, for example in the _application.yaml_: ::: code-group ```yaml [srv/src/main/resources/application.yaml] cds: outbox: services: - DefaultOutboxOrdered: - maxAttempts: 10 - # ordered: true DefaultOutboxUnordered: maxAttempts: 10 - # ordered: false ``` ::: You have the following configuration options: - `maxAttempts` (default `10`): The number of unsuccessful emits until the message is ignored. It still remains in the database table. -- `ordered` (default `true`): If this flag is enabled, the outbox instance processes the entries in the order they have been submitted to it. Otherwise, the outbox may process entries randomly and in parallel, by leveraging outbox processors running in multiple application instances. This option can't be changed for the default persistent outboxes. The persistent outbox stores the last error that occurred, when trying to emit the message of an entry. The error is stored in the element `lastError` of the entity `cds.outbox.Messages`. +::: info +Additionally, CAP Java creates a `DefaultOutboxOrdered` outbox, which is used by [messaging services](../java/messaging). It can be configured similarly via `cds.outbox.services.DefaultOutboxOrdered`. +::: + ### Configuring Custom Outboxes { #custom-outboxes} Custom persistent outboxes can be configured using the `cds.outbox.services` section, for example in the _application.yaml_: @@ -416,42 +393,392 @@ void handleAuditLogProcessingErrors(OutboxMessageEventContext context) { [Learn more about `EventContext.proceed()`.](./event-handlers/#proceed-on){.learn-more} -## Shared Outbox { #shared-outbox} +## Outbox Task Scheduling -A shared outbox is a custom outbox configured with a dedicated persistence service. In this case, the outbox uses the assigned persistence service for all submits and lookups, independent from the current tenant context. This is useful in scenarios where outbox entries need to be stored and processed in a tenant-independent manner. +The CAP Java provides an outbox-based task scheduling mechanism that allows services to emit events on a defined schedule. This enables recurring jobs, delayed execution, and cron-based task automation — all built on top of the existing outbox infrastructure. -To configure a shared outbox, first create an additional persistence service for the dedicated database. Then, assign it to a custom outbox using the `persistenceService` parameter. -#### 1. Create an Additional Persistence Service +The scheduling feature consists of two main components: -Define a persistence service that points to your shared database binding: +| Component | Layer | Purpose | +|-----------|-------|---------| +| [`Schedule`](#schedule-api) | Technical | Defines *when* and *how often* a task executes | +| [`Schedulable`](#schedulable-api) | Logical (CDS Service) | Wraps a CDS service so that emitted events are scheduled | -::: code-group -```yaml [srv/src/main/resources/application.yaml] -cds: - persistence.services: - my-shared-ps: - binding: "my-shared-hdi" + +### Schedule API + +**Package:** `com.sap.cds.services.outbox` +**Class:** `Schedule` + +The `Schedule` class defines the timing configuration for an outbox task. It uses a fluent builder pattern. + +#### Creating a Schedule + +```java +import com.sap.cds.services.outbox.Schedule; +import java.time.Duration; + +// Immediate execution (default) +Schedule now = Schedule.NOW; + +// Delayed execution — run once after 30 seconds +Schedule delayed = Schedule.create() + .after(Duration.ofSeconds(30)); + +// Recurring with fixed delay — every 5 minutes, starting immediately +Schedule recurring = Schedule.create() + .every(Duration.ofMinutes(5)); + +// Recurring with initial delay — first run after 10s, then every 5 minutes +Schedule delayedRecurring = Schedule.create() + .after(Duration.ofSeconds(10)) + .every(Duration.ofMinutes(5)); + +// Cron-based — every weekday at 8:00 AM +Schedule cronBased = Schedule.create() + .cron("0 0 8 * * MON-FRI"); ``` -::: -[Learn more about additional Persistence Services.](./cqn-services/persistence-services#additional-persistence-services){.learn-more} +#### Properties -#### 2. Configure the Shared Outbox +| Method | Description | Default | +|--------|-------------|---------| +| `taskName(String)` | Explicitly names the task, making it a **singleton** with **upsert** semantics (see [Named Tasks](#named-singleton-tasks)) | Event name (for scheduled tasks) | +| `after(Duration)` | Initial delay before first execution | `Duration.ZERO` | +| `every(Duration)` | Delay between recurring executions (after each successful run) | None (single execution) | +| `cron(String)` | Spring Cron Expression for recurring execution | None | +| `cancel()` | Marks the named task for cancellation | `false` | -Create a custom outbox and assign the persistence service using the `persistenceService` parameter: +#### Constraints -::: code-group -```yaml [srv/src/main/resources/application.yaml] -cds: - outbox: - services: - MySharedOutbox: - persistenceService: "my-shared-ps" +- **`cron`** is mutually exclusive with `after` and `every`. Combining them throws `IllegalArgumentException`. +- **`every`** determines the delay *after* a successful execution, not a fixed-rate interval. + + +#### Named (Singleton) Tasks + +All scheduled tasks (using any `Schedule` other than `Schedule.NOW`) are **singletons**. The task name determines the outbox message ID, ensuring only one active instance per name exists at any time. + +- If an explicit `taskName` is set via `.taskName(...)`, it is used as the task name. +- If no explicit `taskName` is set, the **event name** is used as the task name. + +Re-submitting a task with the same name **replaces** the existing entry entirely — the schedule, message content, and execution timestamp are all updated to reflect the latest submission. This follows **last-write-wins** semantics. + +```java +// Only one "daily-cleanup" task will exist, regardless of how often this code runs +Schedule cleanup = Schedule.create() + .taskName("daily-cleanup") + .cron("0 0 2 * * *"); // daily at 2 AM +``` + +```java +// Initial submission: run every hour +Schedule hourly = Schedule.create() + .taskName("sync-job") + .every(Duration.ofHours(1)); +outboxService.submit("sync/trigger", message1, hourly); + +// Later: change to every 30 minutes — replaces the existing "sync-job" +Schedule every30Min = Schedule.create() + .taskName("sync-job") + .every(Duration.ofMinutes(30)); +outboxService.submit("sync/trigger", message2, every30Min); + +// Result: only ONE task exists with the 30-minute schedule and message2 content ``` + +> **Important:** Both the schedule *and* the message payload are replaced. If you only want to update the timing, you must still provide the full message content. + +The upsert mechanism is safe for concurrent submissions. If a named task is re-submitted while it is currently being processed, the new submission is preserved and will be executed according to the updated schedule after the current execution completes. + +::: warning +If you need multiple independent tasks for the same event (e.g., per-user reminders), you **must** set an explicit `taskName` to distinguish them. Without it, all submissions for the same event share one task name and re-submissions replace the existing task. ::: -With this configuration, the outbox `MySharedOutbox` uses the persistence service `my-shared-ps` for all submit and lookup operations. Since this persistence service points to a dedicated database, the outbox operates independently from the tenant context — outbox entries are stored and retrieved from the shared database regardless of which tenant triggers the processing. + + +#### Cancelling a Scheduled Task + +Named tasks can be cancelled: + +```java +Schedule cancelCleanup = Schedule.create() + .taskName("daily-cleanup") + .cancel(); + +// Submit the cancellation +outboxService.submit("maintenance/cleanup", message, cancelCleanup); +``` + +If no explicit `taskName` is set, the event name is used to identify the task to cancel: + +```java +Schedule cancel = Schedule.create() + .cancel(); + +// Cancels the task identified by the event name "sync/trigger" +outboxService.submit("sync/trigger", message, cancel); +``` + +#### Cancellation Behavior + +When a cancellation is submitted, the named task is **deleted** from the outbox so that no future executions will occur. However, a **currently running execution will complete** — cancellation does not interrupt in-flight processing. + +**Key details:** + +| Aspect | Behavior | +|--------|----------| +| Future executions | Prevented — task is removed from the schedule | +| Currently running execution | **Completes** — not interrupted | +| At most one additional execution | Possible if the task was already picked up for processing | +| Cancelling a non-existent task | Silent no-op (no error thrown) | +| Cancellation without explicit `taskName` | Cancels the task identified by the event name | + + +#### Cron Expression Syntax + +The cron expression follows the [Spring Cron Expression](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/support/CronExpression.html) format with **6 fields**: + +``` +┌───────── second (0-59) +│ ┌───────── minute (0-59) +│ │ ┌───────── hour (0-23) +│ │ │ ┌───────── day of month (1-31) +│ │ │ │ ┌───────── month (1-12 or JAN-DEC) +│ │ │ │ │ ┌───────── day of week (0-7 or MON-SUN, 0 and 7 = Sunday) +│ │ │ │ │ │ +* * * * * * +``` + +**Examples:** + +| Expression | Description | +|-----------|-------------| +| `0 0 * * * *` | Every hour | +| `0 */15 * * * *` | Every 15 minutes | +| `0 0 8 * * MON-FRI` | Weekdays at 8:00 AM | +| `0 0 2 * * *` | Daily at 2:00 AM | +| `0 0 0 1 * *` | First day of every month at midnight | + + +**Restrictions:** + +- **`cron` is mutually exclusive** with both `after` and `every`. Setting `cron` after `after`/`every` (or vice versa) throws `IllegalArgumentException`. +- **`every` without `after`** starts the first execution immediately (with zero delay), then applies `every` between subsequent executions. +- **Cron expressions that never match** (e.g., February 30th) are silently discarded — the task is marked as completed without ever executing. +- **All times are evaluated in UTC.** + + +### Schedulable API + +**Package:** `com.sap.cds.services.outbox` +**Interface:** `Schedulable` + +The `Schedulable` interface provides the **logical CDS service layer** for scheduling. It wraps any CDS `Service` so that all events emitted to it are automatically scheduled via the outbox. + +### Core Concept + +When you call `OutboxService.outboxed(service)`, the returned proxy **always** implements `Schedulable`. This means you can: + +1. Use the outboxed service for immediate async execution (default outbox behavior) +2. Cast to `Schedulable` and call `.scheduled(schedule)` to get a service proxy whose events are scheduled + +### Creating a Schedulable Service + +```java +import com.sap.cds.services.outbox.OutboxService; +import com.sap.cds.services.outbox.Schedulable; +import com.sap.cds.services.outbox.Schedule; +import com.sap.cds.services.messaging.MessagingService; + +// Option 1: Using the static factory method +Schedulable schedulable = Schedulable.of(messagingService, outboxService); +MessagingService scheduled = schedulable.scheduled( + Schedule.create().every(Duration.ofMinutes(5)) +); + +// Option 2: Direct cast from outboxed service +MessagingService outboxed = outboxService.outboxed(messagingService); +Schedulable schedulable = (Schedulable) outboxed; +MessagingService scheduled = schedulable.scheduled( + Schedule.create().cron("0 0 */2 * * *") // every 2 hours +); +``` + +### Using a Scheduled Service + +Once you have a scheduled service instance, use it exactly like the original service. All emitted events will be stored in the outbox with the configured schedule: + +```java +// All events emitted to 'scheduled' will follow the defined schedule +scheduled.emit("myTopic", messageData); +``` + +--- + +### End-to-End Examples + +#### Example 1: Recurring Data Sync Every 10 Minutes + +```java +@Autowired +private OutboxService outboxService; + +@Autowired +private MessagingService messagingService; + +public void setupRecurringSync() { + Schedule every10Min = Schedule.create() + .taskName("data-sync") + .every(Duration.ofMinutes(10)); + + MessagingService scheduled = Schedulable.of(messagingService, outboxService) + .scheduled(every10Min); + + // This event will be emitted every 10 minutes + scheduled.emit("sync/trigger", Map.of("source", "system-a")); +} +``` + +#### Example 2: Delayed One-Time Notification + +```java +public void scheduleReminder(String userId) { + Schedule in24Hours = Schedule.create() + .taskName("reminder-" + userId) // explicit name ensures one task per user + .after(Duration.ofHours(24)); + + MessagingService scheduled = Schedulable.of(messagingService, outboxService) + .scheduled(in24Hours); + + // Will be emitted once, 24 hours from now + scheduled.emit("notifications/reminder", Map.of("userId", userId)); +} +``` + +#### Example 3: Cron-Based Daily Report + +```java +public void setupDailyReport() { + Schedule dailyAt6AM = Schedule.create() + .taskName("daily-report") + .cron("0 0 6 * * *"); + + MessagingService scheduled = Schedulable.of(messagingService, outboxService) + .scheduled(dailyAt6AM); + + scheduled.emit("reports/daily", Map.of("type", "summary")); +} +``` + +#### Example 4: Cancelling a Recurring Task + +```java +public void stopDailyReport() { + Schedule cancel = Schedule.create() + .taskName("daily-report") + .cancel(); + + // Submit the cancellation through the outbox + outboxService.submit("reports/daily", outboxMessage, cancel); +} +``` + +#### Example 5: Using the OutboxService Directly + +For lower-level control, you can submit messages with a schedule directly: + +```java +public void submitScheduledMessage() { + OutboxMessage message = createOutboxMessage(); + + Schedule schedule = Schedule.create() + .taskName("cleanup-job") + .after(Duration.ofMinutes(5)) + .every(Duration.ofHours(1)); + + // Submit directly to the outbox with scheduling + outboxService.submit("maintenance/cleanup", message, schedule); +} +``` + +--- + +### Execution Semantics + +#### Recurring Task Timing + +For `every`-based schedules, the next execution is calculated **after a successful execution**: + +``` +Time ──────────────────────────────────────────────────────► + +submit execute execute execute + │──after──►│──── every ───►│──── every ───►│ + t₀ t₁ t₂ t₃ +``` + +For `cron`-based schedules, the next execution time is determined by evaluating the cron expression after the last successful execution. + +#### Singleton Behavior + +All scheduled tasks are singletons — each task has a name (explicit or derived from the event) and only one active instance per name can exist at a time. Re-submitting a task with the same name **replaces** the existing entry. This is ideal for recurring background jobs that should not overlap. + +#### Outbox Guarantees + +Scheduled tasks inherit the standard outbox guarantees: +- **At-least-once delivery** — tasks are retried on failure +- **Transactional** — task submission is part of the current transaction (persistent outbox) +- **Tenant-aware** — tasks execute in the context of the tenant that created them + +--- + + +### API Reference Summary + +#### Schedule + +```java +public class Schedule { + static Schedule NOW; // Immediate execution + static Schedule create(); // Builder entry point + static Schedule of(Map map); // Deserialize from map + + Schedule taskName(String name); // Singleton task name + Schedule after(Duration delay); // Initial delay + Schedule every(Duration interval); // Recurring interval + Schedule cron(String expression); // Spring Cron Expression + Schedule cancel(); // Mark for cancellation + + Optional taskName(); // Get task name + Duration after(); // Get initial delay + Optional every(); // Get recurring interval + Optional cron(); // Get cron expression + boolean isCanceled(); // Check cancellation flag + Map toMap(); // Serialize to map +} +``` + +#### Schedulable + +```java +public interface Schedulable { + static Schedulable of(S service, OutboxService outbox); + T scheduled(Schedule schedule); +} +``` + +#### OutboxService (schedule-related) + +```java +public interface OutboxService extends Service { + void submit(String event, OutboxMessage message, Schedule schedule); + S outboxed(S service); // returned proxy implements Schedulable +} +``` + + ## Outbox Collector Strategies { #outbox-collector-strategies} @@ -507,10 +834,7 @@ For applications with a large number of tenants, traversing all tenants can caus Instead of iterating over all tenants, the hot-tenant task tracks which tenants have been recently active and only triggers the outbox collector for those tenants. Lookups are well distributed over time to avoid activity jams. -The hot-tenant task requires: - -1. The outbox scheduler must be enabled (`cds.outbox.persistent.scheduler.enabled: true`). -2. A [shared outbox](#shared-outbox) must be configured — the hot-tenant task uses the shared outbox to persist tenant activity records independently from the tenant context. +The hot-tenant task requires the outbox scheduler to be enabled (`cds.outbox.persistent.scheduler.enabled: true`). ::: code-group ```yaml [srv/src/main/resources/application.yaml] @@ -521,20 +845,32 @@ cds: enabled: true hotTenantTask: enabled: true - sharedOutbox: "MySharedOutbox" maxTaskDelay: PT2H - services: - MySharedOutbox: - persistenceService: "my-shared-ps" ``` ::: The configuration options are: -- `sharedOutbox`: The name of the shared outbox service used to store tenant activity records. This outbox must be configured with a dedicated persistence service as described in the [Shared Outbox](#shared-outbox) section. - `maxTaskDelay` (default `PT2H`): The maximum time to wait after a tenant event before checking that tenant's outbox. Lookups are distributed within this window to spread the load evenly. -[Learn more about configuring a Shared Outbox.](#shared-outbox){.learn-more} +#### Persistence for Hot-Tenant Tracking + +The hot-tenant task manages tenant activity records centrally in the provider persistence. By default, the MTXs persistence (T0 tenant) is used. + +If the application uses a custom provider persistence bound, for instance, via an HDI binding, the property `cds.multiTenancy.provider.persistenceService` can reference the persistence service to use for the hot-tenant task: + +::: code-group +```yaml [srv/src/main/resources/application.yaml] +cds: + multiTenancy: + provider: + persistenceService: "my-custom-ps" +``` +::: + +::: warning +If you previously ran with the default MTXs/T0 persistence and switch to a custom provider persistence, the currently tracked hot tenants will be lost — there is no automatic migration. Plan accordingly before changing this configuration. +::: ## Outbox Dead Letter Queue From 4546d04cabee1f9885869957ceb3f6adb4a62cd1 Mon Sep 17 00:00:00 2001 From: d040506 Date: Mon, 22 Jun 2026 09:24:49 +0200 Subject: [PATCH 3/6] updated corresponding the PR review comments --- java/outbox.md | 142 +++++++++++++------------------------------------ 1 file changed, 36 insertions(+), 106 deletions(-) diff --git a/java/outbox.md b/java/outbox.md index 3fe9cf4b72..714136c7fa 100644 --- a/java/outbox.md +++ b/java/outbox.md @@ -397,15 +397,6 @@ void handleAuditLogProcessingErrors(OutboxMessageEventContext context) { The CAP Java provides an outbox-based task scheduling mechanism that allows services to emit events on a defined schedule. This enables recurring jobs, delayed execution, and cron-based task automation — all built on top of the existing outbox infrastructure. - -The scheduling feature consists of two main components: - -| Component | Layer | Purpose | -|-----------|-------|---------| -| [`Schedule`](#schedule-api) | Technical | Defines *when* and *how often* a task executes | -| [`Schedulable`](#schedulable-api) | Logical (CDS Service) | Wraps a CDS service so that emitted events are scheduled | - - ### Schedule API **Package:** `com.sap.cds.services.outbox` @@ -416,8 +407,6 @@ The `Schedule` class defines the timing configuration for an outbox task. It use #### Creating a Schedule ```java -import com.sap.cds.services.outbox.Schedule; -import java.time.Duration; // Immediate execution (default) Schedule now = Schedule.NOW; @@ -477,13 +466,13 @@ Schedule cleanup = Schedule.create() Schedule hourly = Schedule.create() .taskName("sync-job") .every(Duration.ofHours(1)); -outboxService.submit("sync/trigger", message1, hourly); +outboxService.submit("sync/trigger", message0, hourly); // Later: change to every 30 minutes — replaces the existing "sync-job" Schedule every30Min = Schedule.create() .taskName("sync-job") .every(Duration.ofMinutes(30)); -outboxService.submit("sync/trigger", message2, every30Min); +outboxService.submit("sync/trigger", message1, every30Min); // Result: only ONE task exists with the 30-minute schedule and message2 content ``` @@ -508,7 +497,7 @@ Schedule cancelCleanup = Schedule.create() .cancel(); // Submit the cancellation -outboxService.submit("maintenance/cleanup", message, cancelCleanup); +outboxService.submit("maintenance/cleanup", null, cancelCleanup); ``` If no explicit `taskName` is set, the event name is used to identify the task to cancel: @@ -518,7 +507,7 @@ Schedule cancel = Schedule.create() .cancel(); // Cancels the task identified by the event name "sync/trigger" -outboxService.submit("sync/trigger", message, cancel); +outboxService.submit("sync/trigger", null, cancel); ``` #### Cancellation Behavior @@ -587,10 +576,6 @@ When you call `OutboxService.outboxed(service)`, the returned proxy **always** i ### Creating a Schedulable Service ```java -import com.sap.cds.services.outbox.OutboxService; -import com.sap.cds.services.outbox.Schedulable; -import com.sap.cds.services.outbox.Schedule; -import com.sap.cds.services.messaging.MessagingService; // Option 1: Using the static factory method Schedulable schedulable = Schedulable.of(messagingService, outboxService); @@ -735,72 +720,53 @@ Scheduled tasks inherit the standard outbox guarantees: --- -### API Reference Summary - -#### Schedule +## Outbox Collector Strategies { #outbox-collector-strategies} -```java -public class Schedule { - static Schedule NOW; // Immediate execution - static Schedule create(); // Builder entry point - static Schedule of(Map map); // Deserialize from map - - Schedule taskName(String name); // Singleton task name - Schedule after(Duration delay); // Initial delay - Schedule every(Duration interval); // Recurring interval - Schedule cron(String expression); // Spring Cron Expression - Schedule cancel(); // Mark for cancellation - - Optional taskName(); // Get task name - Duration after(); // Get initial delay - Optional every(); // Get recurring interval - Optional cron(); // Get cron expression - boolean isCanceled(); // Check cancellation flag - Map toMap(); // Serialize to map -} -``` +In a multitenant environment, outbox entries reside in tenant-specific persistences. The outbox collector is triggered when events are submitted to the outbox. However, if an application instance crashes, unprocessed outbox entries for a tenant are only retried when that tenant next produces a new outbox event. If after a crash, a tenant becomes inactive or has a long period until its next outbox submission, the remaining entries stay unprocessed until the tenant triggers a new event. -#### Schedulable +To address this, CAP Java provides two scheduler-based strategies that periodically check tenant outboxes for unprocessed entries. Both strategies are disabled by default and must be explicitly enabled. -```java -public interface Schedulable { - static Schedulable of(S service, OutboxService outbox); - T scheduled(Schedule schedule); -} -``` +### Hot-Tenant Task { #hot-tenant-task} -#### OutboxService (schedule-related) +Instead of iterating over all tenants, the hot-tenant task tracks which tenants have been recently active and only triggers the outbox collector for those tenants. Lookups are well distributed over time to avoid activity jams. -```java -public interface OutboxService extends Service { - void submit(String event, OutboxMessage message, Schedule schedule); - S outboxed(S service); // returned proxy implements Schedulable -} +::: code-group +```yaml [srv/src/main/resources/application.yaml] +cds: + outbox: + persistent: + scheduler: + hotTenantTask: + enabled: true + maxTaskDelay: PT2H ``` +::: +The configuration options are: +- `maxTaskDelay` (default `PT2H`): The maximum time to wait after a tenant event before checking that tenant's outbox. Lookups are distributed within this window to spread the load evenly. +#### Persistence for Hot-Tenant Tracking -## Outbox Collector Strategies { #outbox-collector-strategies} - -In a multitenant environment, outbox entries reside in tenant-specific persistences. The outbox collector is triggered when events are submitted to the outbox. However, if an application instance crashes, unprocessed outbox entries for a tenant are only retried when that tenant next produces a new outbox event. If after a crash, a tenant becomes inactive or has a long period until its next outbox submission, the remaining entries stay unprocessed until the tenant triggers a new event. - -To address this, CAP Java provides two scheduler-based strategies that periodically check tenant outboxes for unprocessed entries. Both strategies are disabled by default and must be explicitly enabled. +The hot-tenant task manages tenant activity records centrally in the provider persistence. By default, the MTXs persistence (T0 tenant) is used. -::: tip Prerequisite -The outbox scheduler must be enabled for both strategies. Set `cds.outbox.persistent.scheduler.enabled` to `true`. -::: +If the application uses a custom provider persistence bound, for instance, via an HDI binding, the property `cds.multiTenancy.provider.persistenceService` can reference the persistence service to use for the hot-tenant task: ::: code-group ```yaml [srv/src/main/resources/application.yaml] cds: - outbox: - persistent: - scheduler: - enabled: true + multiTenancy: + provider: + persistenceService: "my-custom-ps" ``` ::: +::: warning +If you previously ran with the default MTXs/T0 persistence and switch to a custom provider persistence, the currently tracked hot tenants will be lost — there is no automatic migration. Plan accordingly before changing this configuration. +::: + + + ### All-Tenants Task { #all-tenants-task} The all-tenants task periodically iterates over **all** tenant outboxes and triggers the collector for each tenant. It acts as a safety net to ensure no outbox entries are missed, regardless of tenant activity. @@ -830,47 +796,11 @@ The configuration options are: For applications with a large number of tenants, traversing all tenants can cause significant overhead due to tenant context switches. This may impact application performance. Consider the [Hot-Tenant Task](#hot-tenant-task) as a lighter alternative that only checks recently active tenants. ::: -### Hot-Tenant Task { #hot-tenant-task} - -Instead of iterating over all tenants, the hot-tenant task tracks which tenants have been recently active and only triggers the outbox collector for those tenants. Lookups are well distributed over time to avoid activity jams. - -The hot-tenant task requires the outbox scheduler to be enabled (`cds.outbox.persistent.scheduler.enabled: true`). -::: code-group -```yaml [srv/src/main/resources/application.yaml] -cds: - outbox: - persistent: - scheduler: - enabled: true - hotTenantTask: - enabled: true - maxTaskDelay: PT2H -``` -::: - -The configuration options are: - -- `maxTaskDelay` (default `PT2H`): The maximum time to wait after a tenant event before checking that tenant's outbox. Lookups are distributed within this window to spread the load evenly. - -#### Persistence for Hot-Tenant Tracking - -The hot-tenant task manages tenant activity records centrally in the provider persistence. By default, the MTXs persistence (T0 tenant) is used. - -If the application uses a custom provider persistence bound, for instance, via an HDI binding, the property `cds.multiTenancy.provider.persistenceService` can reference the persistence service to use for the hot-tenant task: - -::: code-group -```yaml [srv/src/main/resources/application.yaml] -cds: - multiTenancy: - provider: - persistenceService: "my-custom-ps" -``` +::: tip Prerequisite +Both strategies require the outbox scheduler to be enabled. By default, `cds.outbox.persistent.scheduler.enabled` is set to `true`. Set this property to false if you want to disable outbox scheduling. ::: -::: warning -If you previously ran with the default MTXs/T0 persistence and switch to a custom provider persistence, the currently tracked hot tenants will be lost — there is no automatic migration. Plan accordingly before changing this configuration. -::: ## Outbox Dead Letter Queue @@ -883,7 +813,7 @@ Once the maximum number of attempts is exceeded, the corresponding entry is not ::: warning Changing configuration between deployments -It's possible to increase the value of the configuration `cds.outbox.services..maxAttempts` in between of deployments. Older entries which have reached their max attempts in the past would be retried automatically after deployment of the new microservice version. If the dead letter queue has a large size, this leads to unintended load on the system. +Both strategies require the outbox scheduler to be enabled. Scheduling is enabled by default `cds.outbox.persistent.scheduler.enabled=true`. Ensure that this property is not set to `false`, as disabling the scheduler prevents outbox messages scheduling. ::: From 044cc926f312beebefda50acd540ff3a28aa0f22 Mon Sep 17 00:00:00 2001 From: d040506 Date: Mon, 22 Jun 2026 14:51:25 +0200 Subject: [PATCH 4/6] removed the deprecated taskName() method by using the new introduced as() method --- java/outbox.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/java/outbox.md b/java/outbox.md index 714136c7fa..11e719db73 100644 --- a/java/outbox.md +++ b/java/outbox.md @@ -433,7 +433,7 @@ Schedule cronBased = Schedule.create() | Method | Description | Default | |--------|-------------|---------| -| `taskName(String)` | Explicitly names the task, making it a **singleton** with **upsert** semantics (see [Named Tasks](#named-singleton-tasks)) | Event name (for scheduled tasks) | +| `as(String)` | Explicitly names the task, making it a **singleton** with **upsert** semantics (see [Named Tasks](#named-singleton-tasks)) | Event name (for scheduled tasks) | | `after(Duration)` | Initial delay before first execution | `Duration.ZERO` | | `every(Duration)` | Delay between recurring executions (after each successful run) | None (single execution) | | `cron(String)` | Spring Cron Expression for recurring execution | None | @@ -449,28 +449,28 @@ Schedule cronBased = Schedule.create() All scheduled tasks (using any `Schedule` other than `Schedule.NOW`) are **singletons**. The task name determines the outbox message ID, ensuring only one active instance per name exists at any time. -- If an explicit `taskName` is set via `.taskName(...)`, it is used as the task name. -- If no explicit `taskName` is set, the **event name** is used as the task name. +- If an explicit name is set via `.as(...)`, it is used as the task name. +- If no explicit name is set, the **event name** is used as the task name. Re-submitting a task with the same name **replaces** the existing entry entirely — the schedule, message content, and execution timestamp are all updated to reflect the latest submission. This follows **last-write-wins** semantics. ```java // Only one "daily-cleanup" task will exist, regardless of how often this code runs Schedule cleanup = Schedule.create() - .taskName("daily-cleanup") + .as("daily-cleanup") .cron("0 0 2 * * *"); // daily at 2 AM ``` ```java // Initial submission: run every hour Schedule hourly = Schedule.create() - .taskName("sync-job") + .as("sync-job") .every(Duration.ofHours(1)); outboxService.submit("sync/trigger", message0, hourly); // Later: change to every 30 minutes — replaces the existing "sync-job" Schedule every30Min = Schedule.create() - .taskName("sync-job") + .as("sync-job") .every(Duration.ofMinutes(30)); outboxService.submit("sync/trigger", message1, every30Min); @@ -482,7 +482,7 @@ outboxService.submit("sync/trigger", message1, every30Min); The upsert mechanism is safe for concurrent submissions. If a named task is re-submitted while it is currently being processed, the new submission is preserved and will be executed according to the updated schedule after the current execution completes. ::: warning -If you need multiple independent tasks for the same event (e.g., per-user reminders), you **must** set an explicit `taskName` to distinguish them. Without it, all submissions for the same event share one task name and re-submissions replace the existing task. +If you need multiple independent tasks for the same event (e.g., per-user reminders), you **must** set an explicit name via `.as(...)` to distinguish them. Without it, all submissions for the same event share one task name and re-submissions replace the existing task. ::: @@ -493,14 +493,14 @@ Named tasks can be cancelled: ```java Schedule cancelCleanup = Schedule.create() - .taskName("daily-cleanup") + .as("daily-cleanup") .cancel(); // Submit the cancellation outboxService.submit("maintenance/cleanup", null, cancelCleanup); ``` -If no explicit `taskName` is set, the event name is used to identify the task to cancel: +If no explicit name is set via `.as(...)`, the event name is used to identify the task to cancel: ```java Schedule cancel = Schedule.create() @@ -522,7 +522,7 @@ When a cancellation is submitted, the named task is **deleted** from the outbox | Currently running execution | **Completes** — not interrupted | | At most one additional execution | Possible if the task was already picked up for processing | | Cancelling a non-existent task | Silent no-op (no error thrown) | -| Cancellation without explicit `taskName` | Cancels the task identified by the event name | +| Cancellation without explicit name | Cancels the task identified by the event name | #### Cron Expression Syntax @@ -615,7 +615,7 @@ private MessagingService messagingService; public void setupRecurringSync() { Schedule every10Min = Schedule.create() - .taskName("data-sync") + .as("data-sync") .every(Duration.ofMinutes(10)); MessagingService scheduled = Schedulable.of(messagingService, outboxService) @@ -631,7 +631,7 @@ public void setupRecurringSync() { ```java public void scheduleReminder(String userId) { Schedule in24Hours = Schedule.create() - .taskName("reminder-" + userId) // explicit name ensures one task per user + .as("reminder-" + userId) // explicit name ensures one task per user .after(Duration.ofHours(24)); MessagingService scheduled = Schedulable.of(messagingService, outboxService) @@ -647,7 +647,7 @@ public void scheduleReminder(String userId) { ```java public void setupDailyReport() { Schedule dailyAt6AM = Schedule.create() - .taskName("daily-report") + .as("daily-report") .cron("0 0 6 * * *"); MessagingService scheduled = Schedulable.of(messagingService, outboxService) @@ -662,7 +662,7 @@ public void setupDailyReport() { ```java public void stopDailyReport() { Schedule cancel = Schedule.create() - .taskName("daily-report") + .as("daily-report") .cancel(); // Submit the cancellation through the outbox @@ -679,7 +679,7 @@ public void submitScheduledMessage() { OutboxMessage message = createOutboxMessage(); Schedule schedule = Schedule.create() - .taskName("cleanup-job") + .as("cleanup-job") .after(Duration.ofMinutes(5)) .every(Duration.ofHours(1)); From 1ce15e853292c9636de7bb99d709d1e1dc62b185 Mon Sep 17 00:00:00 2001 From: d040506 Date: Mon, 22 Jun 2026 16:11:03 +0200 Subject: [PATCH 5/6] updated duration definitions to be human-readable. --- java/outbox.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/java/outbox.md b/java/outbox.md index 11e719db73..46193d0bab 100644 --- a/java/outbox.md +++ b/java/outbox.md @@ -738,13 +738,13 @@ cds: scheduler: hotTenantTask: enabled: true - maxTaskDelay: PT2H + maxTaskDelay: 2h ``` ::: The configuration options are: -- `maxTaskDelay` (default `PT2H`): The maximum time to wait after a tenant event before checking that tenant's outbox. Lookups are distributed within this window to spread the load evenly. +- `maxTaskDelay` (default `2h`): The maximum time to wait after a tenant event before checking that tenant's outbox. Lookups are distributed within this window to spread the load evenly. #### Persistence for Hot-Tenant Tracking @@ -780,17 +780,17 @@ cds: enabled: true allTenantsTask: enabled: true - startDelay: PT30S - interval: PT2H - spreadTime: PT15M + startDelay: 30s + interval: 2h + spreadTime: 15m ``` ::: The configuration options are: -- `startDelay` (default `PT30S`): Delay after application startup before the first execution. -- `interval` (default `PT2H`): Interval between successive executions. -- `spreadTime` (default `PT15M`): Time span over which individual tenant checks are randomly distributed. This avoids a thundering-herd effect where all tenant outboxes are checked simultaneously. +- `startDelay` (default `30s`): Delay after application startup before the first execution. +- `interval` (default `2h`): Interval between successive executions. +- `spreadTime` (default `15m`): Time span over which individual tenant checks are randomly distributed. This avoids a thundering-herd effect where all tenant outboxes are checked simultaneously. ::: warning Performance consideration For applications with a large number of tenants, traversing all tenants can cause significant overhead due to tenant context switches. This may impact application performance. Consider the [Hot-Tenant Task](#hot-tenant-task) as a lighter alternative that only checks recently active tenants. From 60899a3ad8a3f6ed72f3fb8ad95c240177cdef96 Mon Sep 17 00:00:00 2001 From: Mahati Shankar <93712176+smahati@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:54:58 +0200 Subject: [PATCH 6/6] cosmetics --- java/outbox.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/java/outbox.md b/java/outbox.md index 46193d0bab..508083c561 100644 --- a/java/outbox.md +++ b/java/outbox.md @@ -35,7 +35,7 @@ Once the transaction succeeds, the messages are read from the database table and - If an emit wasn't successful, there will be a retry after some (exponentially growing) waiting time. After a maximum number of attempts, the message is ignored for processing and remains in the database table. Even if the app crashes the messages can be redelivered after successful application startup. -CAP Java provides the persistent outbox service `DefaultOutboxUnordered` by default. It is used by the [AuditLog service](../java/auditlog) and registered as the primary Spring bean for `OutboxService`. You can inject it directly without a qualifier: +CAP Java provides the persistent outbox service `DefaultOutboxUnordered` by default. It's used by the [AuditLog service](../java/auditlog) and registered as the primary Spring bean for `OutboxService`. You can inject it directly without a qualifier: ```java @Autowired @@ -395,7 +395,7 @@ void handleAuditLogProcessingErrors(OutboxMessageEventContext context) { ## Outbox Task Scheduling -The CAP Java provides an outbox-based task scheduling mechanism that allows services to emit events on a defined schedule. This enables recurring jobs, delayed execution, and cron-based task automation — all built on top of the existing outbox infrastructure. +CAP Java provides an outbox-based task scheduling mechanism that allows services to emit events on a defined schedule. This mechanism enables recurring jobs, delayed execution, and cron-based task automation — all built on top of the existing outbox infrastructure. ### Schedule API @@ -449,10 +449,10 @@ Schedule cronBased = Schedule.create() All scheduled tasks (using any `Schedule` other than `Schedule.NOW`) are **singletons**. The task name determines the outbox message ID, ensuring only one active instance per name exists at any time. -- If an explicit name is set via `.as(...)`, it is used as the task name. +- If an explicit name is set via `.as(...)`, it's used as the task name. - If no explicit name is set, the **event name** is used as the task name. -Re-submitting a task with the same name **replaces** the existing entry entirely — the schedule, message content, and execution timestamp are all updated to reflect the latest submission. This follows **last-write-wins** semantics. +Re-submitting a task with the same name **replaces** the existing entry entirely — the schedule, message content, and execution timestamp are all updated to reflect the latest submission. Replacement follows **last-write-wins** semantics. ```java // Only one "daily-cleanup" task will exist, regardless of how often this code runs @@ -479,17 +479,17 @@ outboxService.submit("sync/trigger", message1, every30Min); > **Important:** Both the schedule *and* the message payload are replaced. If you only want to update the timing, you must still provide the full message content. -The upsert mechanism is safe for concurrent submissions. If a named task is re-submitted while it is currently being processed, the new submission is preserved and will be executed according to the updated schedule after the current execution completes. +The upsert mechanism is safe for concurrent submissions. If a named task is re-submitted while it's currently being processed, the new submission is preserved and will be executed according to the updated schedule after the current execution completes. ::: warning -If you need multiple independent tasks for the same event (e.g., per-user reminders), you **must** set an explicit name via `.as(...)` to distinguish them. Without it, all submissions for the same event share one task name and re-submissions replace the existing task. +If you need multiple independent tasks for the same event (for example, per-user reminders), you **must** set an explicit name via `.as(...)` to distinguish them. Without it, all submissions for the same event share one task name and re-submissions replace the existing task. ::: -#### Cancelling a Scheduled Task +#### Canceling a Scheduled Task -Named tasks can be cancelled: +Named tasks can be canceled: ```java Schedule cancelCleanup = Schedule.create() @@ -512,7 +512,7 @@ outboxService.submit("sync/trigger", null, cancel); #### Cancellation Behavior -When a cancellation is submitted, the named task is **deleted** from the outbox so that no future executions will occur. However, a **currently running execution will complete** — cancellation does not interrupt in-flight processing. +When a cancellation is submitted, the named task is **deleted** from the outbox so that no future executions occur. However, a **currently running execution will complete** — cancellation doesn't interrupt in-flight processing. **Key details:** @@ -521,7 +521,7 @@ When a cancellation is submitted, the named task is **deleted** from the outbox | Future executions | Prevented — task is removed from the schedule | | Currently running execution | **Completes** — not interrupted | | At most one additional execution | Possible if the task was already picked up for processing | -| Cancelling a non-existent task | Silent no-op (no error thrown) | +| Canceling a non-existent task | Silent no-op (no error thrown) | | Cancellation without explicit name | Cancels the task identified by the event name | @@ -553,9 +553,9 @@ The cron expression follows the [Spring Cron Expression](https://docs.spring.io/ **Restrictions:** -- **`cron` is mutually exclusive** with both `after` and `every`. Setting `cron` after `after`/`every` (or vice versa) throws `IllegalArgumentException`. +- **`cron` cannot be used** with `after` and `every`. Setting `cron` when `after`/`every` is already defined throws `IllegalArgumentException`. - **`every` without `after`** starts the first execution immediately (with zero delay), then applies `every` between subsequent executions. -- **Cron expressions that never match** (e.g., February 30th) are silently discarded — the task is marked as completed without ever executing. +- **Cron expressions that never match** (for example, February 30th) are silently deleted — the task is marked as completed without ever executing. - **All times are evaluated in UTC.** @@ -593,7 +593,7 @@ MessagingService scheduled = schedulable.scheduled( ### Using a Scheduled Service -Once you have a scheduled service instance, use it exactly like the original service. All emitted events will be stored in the outbox with the configured schedule: +Once you have a scheduled service instance, use it exactly like the original service. All emitted events are stored in the outbox with the configured schedule: ```java // All events emitted to 'scheduled' will follow the defined schedule @@ -657,7 +657,7 @@ public void setupDailyReport() { } ``` -#### Example 4: Cancelling a Recurring Task +#### Example 4: Canceling a Recurring Task ```java public void stopDailyReport() { @@ -708,7 +708,7 @@ For `cron`-based schedules, the next execution time is determined by evaluating #### Singleton Behavior -All scheduled tasks are singletons — each task has a name (explicit or derived from the event) and only one active instance per name can exist at a time. Re-submitting a task with the same name **replaces** the existing entry. This is ideal for recurring background jobs that should not overlap. +All scheduled tasks are singletons — each task has a name (explicit or derived from the event) and only one active instance per name can exist at a time. Re-submitting a task with the same name **replaces** the existing entry. This Behavior is ideal for recurring background jobs that should not overlap. #### Outbox Guarantees @@ -762,14 +762,14 @@ cds: ::: ::: warning -If you previously ran with the default MTXs/T0 persistence and switch to a custom provider persistence, the currently tracked hot tenants will be lost — there is no automatic migration. Plan accordingly before changing this configuration. +If you previously ran with the default MTXs/T0 persistence and switch to a custom provider persistence, the currently tracked hot tenants will be lost — there's no automatic migration. Plan accordingly before changing this configuration. ::: ### All-Tenants Task { #all-tenants-task} -The all-tenants task periodically iterates over **all** tenant outboxes and triggers the collector for each tenant. It acts as a safety net to ensure no outbox entries are missed, regardless of tenant activity. +The all-tenants task periodically iterates over **all** tenant outboxes and triggers the collector for each tenant. It acts as a safety net to ensure that no outbox entries are missed, regardless of tenant activity. ::: code-group ```yaml [srv/src/main/resources/application.yaml] @@ -790,7 +790,7 @@ The configuration options are: - `startDelay` (default `30s`): Delay after application startup before the first execution. - `interval` (default `2h`): Interval between successive executions. -- `spreadTime` (default `15m`): Time span over which individual tenant checks are randomly distributed. This avoids a thundering-herd effect where all tenant outboxes are checked simultaneously. +- `spreadTime` (default `15m`): The time span over which individual tenant checks are randomly distributed. This avoids a thundering-herd effect where all tenant outboxes are checked simultaneously. ::: warning Performance consideration For applications with a large number of tenants, traversing all tenants can cause significant overhead due to tenant context switches. This may impact application performance. Consider the [Hot-Tenant Task](#hot-tenant-task) as a lighter alternative that only checks recently active tenants.