diff --git a/.gitignore b/.gitignore
index ac2f31bf8d..dfced3185e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ dist/
.DS_Store
.idea
*.iml
+.claude/
diff --git a/guides/deploy/build.md b/guides/deploy/build.md
index f8055bbd3e..cbad2b0202 100644
--- a/guides/deploy/build.md
+++ b/guides/deploy/build.md
@@ -25,7 +25,7 @@ Build tasks are derived from the CDS configuration and project context. By defau
- _db/_, _srv/_, _app/_ — default root folders of a CAP project
- _fts/_ and its subfolders when using [feature toggles](../extensibility/feature-toggles#enable-feature-toggles)
- CDS model folders and files defined by [required services](../../node.js/cds-env#services)
- - Built-in examples: [persistent queue](../../node.js/queue#persistent-queue) or [MTX-related services](../multitenancy/mtxs#mtx-services-reference)
+ - Built-in examples: [persistent queue](../../node.js/event-queues#persistent-queue) or [MTX-related services](../multitenancy/mtxs#mtx-services-reference)
- Explicit `src` folder configured in the build task
diff --git a/guides/events/_event-queues/EventQueuesScheduling.png b/guides/events/_event-queues/EventQueuesScheduling.png
new file mode 100644
index 0000000000..5cb7113672
Binary files /dev/null and b/guides/events/_event-queues/EventQueuesScheduling.png differ
diff --git a/guides/events/_event-queues/EventQueuesScheduling.svg b/guides/events/_event-queues/EventQueuesScheduling.svg
new file mode 100644
index 0000000000..76b2c95e40
--- /dev/null
+++ b/guides/events/_event-queues/EventQueuesScheduling.svg
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/guides/events/_event-queues/architecture.md b/guides/events/_event-queues/architecture.md
new file mode 100644
index 0000000000..c2d10f0b3d
--- /dev/null
+++ b/guides/events/_event-queues/architecture.md
@@ -0,0 +1,137 @@
+## Overview
+
+
+
+
+
+## Approach
+
+The approach features three independent flows/loops that work as follows:
+
+### 1. Scheduling
+
+_Anybody_ sends/emits a request/event (hereafter simply _event_) to a service (1.1).
+Because this service is _queued_, the event is intercepted and _scheduled_ for execution.
+Via the additional API `srv.schedule()`, it is possible to supply `task`, `after`, and `every` arguments to make the task a _named task_ (see below) and to add delays and/or recurrence.
+
+The _scheduling_ described above is done by passing the event to the _task scheduler_ (1.2).
+The task scheduler has three responsibilities:
+1. Write the _message_ (following the outbox convention) to the tenant database (_t1_), in the same transaction if applicable, for atomicity (1.3a)
+2. Write a _marker_ (see below) to the mtx database (_t0_) that captures that there is "something to do" for tenant _t1_ (1.3b)
+3. Register an _on-commit_ listener that triggers execution of the scheduled task (1.4)
+
+Note: The task scheduler only `UPSERT`s messages and markers.
+
+### 2. Processing
+
+The _tenant task runner_ reads a configurable _chunk_ of messages from the database (2.1) and emits the respective event to the respective (_unqueued_) service (2.2).
+
+Each event is processed _individually_ and _in parallel_, each in its own transaction.
+This must be taken into account when configuring the (default) chunk size.
+
+Events are also executed _exactly once_.
+Two mechanisms ensure this:
+1. _Application-level locking_: _Processable messages_ (see below) are `SELECT`ed `FOR UPDATE` and marked as _processing_.
+ - Alternatively, processable messages are `SELECT`ed `FOR UPDATE` and the lock is held for the entire duration (`legacyLocking: true`).
+ For migration reasons, this is still the default in cds^9, but the default will change in cds^10.
+2. Messages are deleted within the same transaction in which they are processed.
+ - This is not possible with the legacy locking approach, because the reading transaction holds the lock on the message throughout.
+
+After successful processing, the message is deleted from the database (2.3).
+For recurring tasks, the next execution is then scheduled via the task scheduler (2.4).
+
+After failed processing, the message's next attempt is scheduled via the task scheduler (2.4).
+That is, the message is updated by incrementing `attempts`, setting `lastError` and `lastAttemptTimestamp`, and clearing `status`.
+Scheduling the next attempt via the task scheduler is important to ensure that a respective marker is `UPSERT`ed.
+
+Notes:
+- The task processor only `READ`s and `DELETE`s messages.
+- In non-mtx scenarios, the task runner starts on app startup.
+
+### 3. Startup and Recovery
+
+The _mtx task runner_ reads a configurable _chunk_ of markers from the database (3.1) and emits the respective _flush_ event to the respective _tenant task runner_ (3.2).
+A flush event resolves when all _processable messages_ have been processed (3.3).
+Afterwards, all _previous markers_ (see Marker Deduplication) are deleted (3.4).
+
+Notes:
+- It does not matter whether messages were processed successfully or not, because the next attempt is scheduled via the task scheduler, which writes a new marker.
+- The mtx task runner runs on startup (only markers for "hot tenants" exist at that point) and every X minutes thereafter.
+- In the future, mtx will also use the runtime's event queues implementation, so `t0` may contain markers as well as messages.
+
+
+
+## Markers
+
+_Markers_ contain no business data — only information about which queue of which tenant needs to be flushed at what point in time.
+
+### (Tenant-specific) Offset
+
+Because markers serve as a recovery/backup mechanism, their _timestamp_ differs from the _timestamp_ of the queued event.
+Instead, a configurable _offset_ is added.
+
+To reduce the number of markers, they are placed on a configurable _grid_: the timestamp is determined by adding the offset to the original timestamp and then _ceiling_ the result to the next grid point.
+
+However, this can cause bursts of activity because task processing for multiple tenants becomes synchronized.
+To avoid this, an additional _tenant-specific offset_ is added to the ceiled timestamp.
+Because this offset requires no coordination, the tenant identifier (`zone id`/`app_tid`) is used as the seed of a random number generator; its first output, multiplied by the grid interval, becomes the tenant-specific offset.
+
+### Deduplication
+
+During marker selection for processing, there may be multiple "flush t1" markers with different timestamps.
+However, a flush always includes all processable messages, so only a single flush is needed.
+Therefore, a `SELECT DISTINCT` is used to skip logical duplicates, and after the flush, all markers with a timestamp ≤ the selected marker's timestamp are deleted.
+
+
+
+## Named Tasks
+
+_Named tasks_ (or _singleton tasks_) are scheduled events that:
+1. Must exist only once
+2. Have a non-null `task` property that allows them to be identified and addressed
+
+### Concurrency Issue
+
+There is a concurrency issue when scheduling named tasks.
+Database transactions are _read-committed_ by default (on HANA and Postgres), meaning they only see committed data.
+If two parallel transactions (which is common during bootstrapping) both try to schedule the same named task _for the first time_, they will not detect a conflict when `UPSERT`ing that task.
+
+Preventing all but the first commit would require a deferred check.
+Because of the `appid` column for shared HDI containers, this would need to be a `UNIQUE INDEX` (which supports a `WHERE` clause).
+Such a unique index cannot currently be created via cds.
+
+The alternatives are:
+1. Acquire a table lock (which would require executing database-specific plain SQL, at least in Node.js), or
+2. Rely on the primary key constraint of the outbox table by hashing `task` + `appid` into a deterministic `UUID` (or `String(36)`)
+
+
+
+## Messages
+
+### Processable Messages
+
+A message is _processable_ if:
+1. Message timestamp + retry offset (= attempts × some exponential factor) < current time
+2. Attempts < max attempts
+3. Status ≠ `processing` OR the processing status has timed out
+
+### Schema Enhancements
+
+To efficiently manage markers (see Marker Deduplication), some fields currently encoded in `msg` — namely `tenant`, `queue`, and `event` — should be promoted to the top level (cf. https://github.tools.sap/cap/cds/pull/6170).
+
+### Migration Issue
+
+As with the introduction of application-level locking in cds^9, there is also a migration issue with the schema enhancement.
+Old task runners may select messages written by new task schedulers, in which `tenant`, `queue`, and `event` are no longer encoded in `msg`.
+(Because such old task runners are always _tenant task runners_, `tenant` is not relevant here.)
+As a mitigation, `queue` and `event` must continue to be encoded in `msg` until cds^11.
+
+
+
+## TODOs
+
+1. The chunk size should be dynamic, based on the number of available connections.
+2. The `t0` pool min should be 1 to make `UPSERT`ing a marker faster.
+3. Should the message schema include a version property to avoid migration issues in the future (i.e., older runners selecting messages written by newer schedulers)?
+4. Scheduled task runner runs (step 1.4) should probably be combined at some granularity.
+5. Should the task runner also run every X minutes in non-mtx scenarios?
diff --git a/guides/events/assets/event-queues-how-it-works.drawio b/guides/events/assets/event-queues-how-it-works.drawio
new file mode 100644
index 0000000000..eb32e732b6
--- /dev/null
+++ b/guides/events/assets/event-queues-how-it-works.drawio
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/guides/events/assets/event-queues-how-it-works.drawio.svg b/guides/events/assets/event-queues-how-it-works.drawio.svg
new file mode 100644
index 0000000000..20d3d40cb2
--- /dev/null
+++ b/guides/events/assets/event-queues-how-it-works.drawio.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/guides/events/assets/event-queues-motivation.drawio b/guides/events/assets/event-queues-motivation.drawio
new file mode 100644
index 0000000000..291574ddc9
--- /dev/null
+++ b/guides/events/assets/event-queues-motivation.drawio
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/guides/events/assets/event-queues-motivation.drawio.svg b/guides/events/assets/event-queues-motivation.drawio.svg
new file mode 100644
index 0000000000..8c88c8121e
--- /dev/null
+++ b/guides/events/assets/event-queues-motivation.drawio.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/guides/events/event-queues.md b/guides/events/event-queues.md
index 903eeba561..c075ba8c53 100644
--- a/guides/events/event-queues.md
+++ b/guides/events/event-queues.md
@@ -1,62 +1,828 @@
---
synopsis: >
- Transactional Event Queues allow to schedule events and background tasks for asynchronous exactly once processing and withultimate resilience.
+ Transactional Event Queues let you persist events and background tasks in the same database transaction as your business data, then process them asynchronously with retries and a dead letter queue.
status: released
---
# Transactional Event Queues
+The *'Transactional Outbox'* Pattern, generalized {.subtitle}
-{{ $frontmatter.synopsis }}
+Persist events and background tasks in the same database transaction as your business data, then process them asynchronously with retries and a dead letter queue.
+{.abstract}
+> [!tip] Transactional Event Queues – Guiding Principles
+>
+> 1. Queued work is written in the same transaction as your business data → *no phantom events, no lost events*
+> 2. A background runner dispatches it after commit, not during the request → *fast request handling, durable side effects*
+> 3. Failed work is retried with exponential backoff; unrecoverable entries become dead letters → *ultimate resilience*
+>
+> => Application developers stay focused on the domain, not on failure modes.
+
+[toc]:./
[[toc]]
-## Event Queues: Concept
+## Motivation
+
+Distributed side effects are hard to get right.
+An application may commit local data, but a follow-up remote call can still fail because of network errors, service outages, or a process crash.
+
+_Transactional Event Queues_ solve this by storing the follow-up work in the database as part of the **same transaction** as your business data.
+After commit, a background runner executes that work asynchronously and retries failures until they succeed or become dead letters.
+
+
+
+This pattern is widely known as the *'Transactional Outbox'*, but CAP's event queues go beyond outbound messages. They cover four use cases:
+
+- **Outbox** — defer outbound calls to remote services until the transaction succeeds.
+- **Inbox** — acknowledge inbound messages immediately and process them asynchronously.
+- **Background Tasks** — schedule periodic or delayed tasks such as data replication.
+- **Callbacks** — react to completed or failed tasks, enabling SAGA-like compensation patterns.
+
+
+## Quick Start
+
+Event queues are enabled by default — there's nothing to install or activate. Use a queued service when a side effect must only happen after the current transaction commits.
+
+```js
+const xflights = await cds.connect.to('xflights')
+const qd_xflights = cds.queued(xflights)
+
+this.after('CREATE', 'Travels', async (_, req) => {
+ await qd_xflights.send('bookFlight', { travelId: req.data.ID })
+})
+```
+
+This stores the flight booking request in the database together with the travel creation. CAP dispatches it later in the background. If the transaction rolls back, no booking request is sent.
+
+The `xflights` connection in the example stands in for any remote service you've configured under `cds.requires`. The complete setup, including the *XTravels* application and the *xflights* service it consumes, lives in the [@capire/xtravels](https://github.com/capire/xtravels) sample.
+
+
+## Use Cases
+
+### Outbox
+
+The outbox defers outbound calls to remote services until the main transaction succeeds.
+This prevents sending requests to external systems when your transaction might still roll back.
+
+**Example:** In the *XTravels* application, when an agent creates a `Travels` record, the application also has to notify *xflights* to book the actual flight. Without the outbox, the booking call could go out even if the local `Travels` row never commits.
+
+::: code-group
+```js [Node.js]
+const xflights = await cds.connect.to('xflights')
+const qd_xflights = cds.queued(xflights)
+
+this.after('CREATE', 'Travels', async (_, req) => {
+ // Persisted within the current transaction, sent after commit
+ await qd_xflights.send('bookFlight', { travelId: req.data.ID })
+})
+```
+```java [Java]
+@Autowired @Qualifier("XFlightsOutbox")
+OutboxService outbox;
+
+@Autowired @Qualifier(CqnService.DEFAULT_NAME)
+CqnService xflights;
+
+@After(event = CqnService.EVENT_CREATE, entity = Travels_.CDS_NAME)
+void notifyXFlights(List travels) {
+ AsyncCqnService outboxedXFlights = AsyncCqnService.of(xflights, outbox);
+ travels.forEach(t -> outboxedXFlights.emit("bookFlight", Map.of("travelId", t.getId())));
+}
+```
+:::
+
+```js
+// Anti-pattern: remote side effect happens before local commit is safe
+this.after('CREATE', 'Travels', async (_, req) => {
+ await xflights.send('bookFlight', { travelId: req.data.ID })
+})
+```
+
+If the surrounding transaction later fails, the external booking may already exist although the local travel record was rolled back.
+
+[See the *XTravels* sample for a comparable scenario.](https://github.com/capire/xtravels){.learn-more}
+
+Some services are outboxed automatically, including `cds.MessagingService` and `cds.AuditLogService`.
+You don't need to call `cds.queued()` or configure anything extra for these — they use the persistent queue by default.
+
+[Learn more about auto-outboxed services in Node.js.](../../node.js/event-queues#queueing-a-service){.learn-more}
+[Learn more about the outbox in Java.](../../java/event-queues){.learn-more}
+
+
+### Inbox
+
+The inbox mirrors the *'Outbox'* pattern for inbound messages.
+When a message arrives from a broker, the messaging service immediately persists it to the database, acknowledges it to the broker, and schedules its processing.
+
+This brings two advantages:
+
+- **Quick acknowledgment** — the broker doesn't have to wait for your processing to complete, which keeps consumer throughput high under load.
+- **Flatten the curve** — if a burst of messages arrives, they are queued in your database and processed at a controlled pace.
+
+> [!note] Especially useful when brokers don't support redelivery
+> Some message brokers do not allow retriggering delivery or correcting message payloads.
+> With the inbox, failures are handled inside your app via the [dead letter queue](#dead-letter-queue), where you have full control over retry and correction.
+
+Enable the inbox in your configuration:
+
+::: code-group
+```json [Node.js — package.json]
+{
+ "cds": {
+ "requires": {
+ "messaging": {
+ "inboxed": true
+ }
+ }
+ }
+}
+```
+```yaml [Java — application.yaml]
+cds:
+ messaging:
+ services:
+ - name: messaging-name
+ inbox:
+ enabled: true
+```
+:::
+
+::: warning Inboxing shifts failure handling to your application
+With inboxing enabled, the broker considers the message delivered as soon as your app stores it.
+If later processing fails, recovery no longer happens in the broker; it happens in your application's retry and dead letter queue flow.
+:::
+
+
+### Background Tasks
+
+Event queues are not limited to messaging.
+You can schedule arbitrary background tasks such as data replication, cache refresh, or garbage collection.
+
+**Example:** Replicate airport master data from the xflights service every 10 minutes.
+
+::: code-group
+```js [Node.js]
+const xflights = await cds.connect.to('xflights')
+await xflights.schedule('replicate', { entity: 'Airports' }).every('10 minutes')
+```
+:::
+
+> [!note] Java documentation to follow
+> Java has an equivalent scheduling API; documentation is on its way. The Node.js shape on this page applies analogously.
+
+The `schedule()` method is a convenience shortcut for `cds.queued(srv).send(event, data)` with optional timing:
+
+```js
+// Execute once, as soon as possible
+await xflights.schedule('cleanup', { olderThan: '30d' })
+
+// Execute once, after a delay
+await xflights.schedule('cleanup', { olderThan: '30d' }).after('1h')
+
+// Execute repeatedly — supports time strings and cron expressions
+await xflights.schedule('replicate', { entity: 'Airports' }).every('10 minutes')
+await xflights.schedule('replicate', { entity: 'Airports' }).every('*/10 * * * *')
+```
+
+`.after()` accepts milliseconds (as a number) or a time string such as `'1s'`, `'10m'`, `'1h'`.
+`.every()` accepts the same plus a five-field cron expression.
+
+#### Singleton Tasks
+
+Use `srv.schedule.task()` to schedule a *singleton task* — a task identified by name that exists only once:
+
+```js
+// Replace any existing 'replicate' task with a new schedule
+await xflights.schedule.task('replicate', { entity: 'Airports' }).every('10 minutes')
+
+// Remove the task
+await xflights.unschedule.task('replicate')
+```
+
+The event name doubles as the task name. A subsequent call with the same name overwrites the previous schedule (tasks are upserted, not deduplicated). This is convenient for idempotent registration during application startup.
+
+::: tip Real-world example: data federation
+The [data federation guide](../integration/data-federation) uses `srv.schedule().every()` to implement polling-based replication, fetching incremental updates from remote services on a regular interval.
+:::
+
+
+### Callbacks (SAGA Patterns)
+
+In distributed transactions, you often need to react when an asynchronous step completes or fails.
+Event queues support this with `#succeeded` and `#failed` callback events, enabling compensation logic similar to SAGA patterns.
+
+**Example:** After successfully creating a flight booking through *xflights*, the *XTravels* application replicates the full booking back into its own database. If the booking fails, the application updates the local `Travels` row to surface the error in its UI.
+
+::: code-group
+```js [Node.js]
+const xflights = await cds.connect.to('xflights')
+
+// Called when the queued booking succeeds
+xflights.after('bookFlight/#succeeded', async (result, req) => {
+ console.log('Flight booked successfully:', result)
+ // Replicate booking details from remote
+})
+
+// Called when the queued booking fails after max retries
+xflights.after('bookFlight/#failed', async (error, req) => {
+ console.log('Flight booking failed:', error)
+ // Trigger compensation logic
+})
+```
+:::
+
+> [!note] Node.js only
+> Callback events `#succeeded` and `#failed` are currently available in Node.js only. Java doesn't have an equivalent yet, but it's on the roadmap.
+
+::: tip Register on specific events
+Callback handlers must be registered for the specific `#succeeded` or `#failed` events.
+The `*` wildcard handler is not called for these events.
+:::
+
+
+## Guarantees
+
+The core principle is straightforward:
+
+1. Instead of executing side effects directly, you write a message into a database table — **within the current transaction**.
+2. Once the transaction commits, a background runner reads pending messages and dispatches them to the respective service.
+3. If processing succeeds, the message is deleted.
+4. If processing fails, the system retries with exponentially increasing delays.
+5. After a configurable maximum number of attempts, the message is moved to the dead letter queue for manual intervention.
+
+
+
+Because the queued message and your business data share the same database transaction, you get two core guarantees:
+
+- **No phantom events** — if the transaction rolls back, no message is sent.
+- **No lost events** — if the transaction commits, the queued work is persisted and processed eventually.
+
+### Transactional Persistence
+
+Because the queued message is written in the same database transaction as your business data, a rollback also removes the queued message.
+No event is ever dispatched for a transaction that didn't commit.
+
+### Eventual Processing
+
+The persistent queue guarantees transactional persistence and eventual processing.
+For database-backed processing, CAP avoids duplicate execution under normal operation, but handlers should still be idempotent to tolerate rare crash windows or external side effects.
+
+Database changes made during queued processing are committed only if the event is processed successfully.
+
+
+## End-to-End Example
+
+The following example ties together queueing, callbacks, and local state updates.
+It shows a common pattern: create local business data first, then trigger remote work asynchronously, then react to its outcome.
+
+```js
+const cds = require('@sap/cds')
+
+module.exports = class TravelService extends cds.ApplicationService {
+ async init() {
+ const xflights = await cds.connect.to('xflights')
+ const qd_xflights = cds.queued(xflights)
+
+ this.after('CREATE', 'Travels', async (_, req) => {
+ await qd_xflights.send('bookFlight', {
+ travelId: req.data.ID,
+ customerId: req.data.customer_ID
+ })
+ })
+
+ xflights.after('bookFlight/#succeeded', async (_, req) => {
+ await UPDATE('Travels')
+ .set({ status: 'Booked' })
+ .where({ ID: req.data.travelId })
+ })
+
+ xflights.after('bookFlight/#failed', async (err, req) => {
+ await UPDATE('Travels')
+ .set({ status: 'BookingFailed' })
+ .where({ ID: req.data.travelId })
+ req.warn(`Flight booking permanently failed: ${err.message}`)
+ })
+
+ await super.init()
+ }
+}
+```
+
+This example highlights an important design rule:
+use callbacks or persisted status updates for outcomes, not direct return values.
+
+
+## How to Use
+
+To get a side effect dispatched **after** your transaction commits — with the guarantees described above — you write to an event queue rather than calling the service directly. There are two ways to make a service write to an event queue: programmatically, by wrapping a service in `cds.queued()`, or declaratively, by enabling outboxing in configuration. Either way, the trigger from your code is the same — a normal `srv.send()` or `srv.schedule()` call.
+
+> [!tip] When **not** to use event queues
+> If you need an immediate, synchronous response from a remote system, use a normal service call. Queued calls execute asynchronously and discard the direct return value — for purely local logic that finishes inside the current request, an event queue adds nothing.
+
+### Programmatically
+
+#### Triggering a Queued Event
+
+Wrap a service in `cds.queued()` (Node.js) or `AsyncCqnService.of()` (Java) and dispatch normally. The call is persisted to the event queue inside your current transaction and processed asynchronously after commit.
+
+For the Node.js shape, see [Quick Start](#quick-start) and the [*Outbox* use case](#outbox). In Java, you can also wrap a service at runtime through the service catalog rather than wiring through Spring:
+
+::: code-group
+```java [Java]
+OutboxService outbox = runtime.getServiceCatalog()
+ .getService(OutboxService.class, "XFlightsOutbox");
+CqnService xflights = runtime.getServiceCatalog()
+ .getService(CqnService.class, "xflights");
+
+AsyncCqnService queued = AsyncCqnService.of(xflights, outbox);
+queued.emit("bookFlight", Map.of("travelId", "T-42"));
+```
+:::
+
+A queued call changes _when_ work happens and _what the caller can expect back_:
+
+- A **direct** call returns the remote service's result (or error) and only then commits the local transaction.
+- A **queued** call writes the message to the queue inside the local transaction and returns. The actual remote dispatch happens after commit, in the background.
+
+> [!warning] Queued calls discard the direct return value
+> A queued service persists the request and returns after the message is stored, not after the remote operation finishes.
+> Any return value from `send()` or `run()` is therefore not available to the caller. To act on the outcome, register a [callback handler](#callbacks-saga-patterns) on `#succeeded` or `#failed`.
+
+::: tip `await` is still needed
+Even though processing is asynchronous, you still need to `await` because the message is written to the database within the current transaction.
+:::
+
+To unwrap a queued service back to its synchronous original:
+
+::: code-group
+```js [Node.js]
+const xflights = cds.unqueued(qd_xflights)
+```
+```java [Java]
+CqnService xflights = outbox.unboxed(outboxedXFlights);
+```
+:::
+
+##### Scheduling a Task
+
+For delayed or recurring work, use the `schedule()` shortcut — equivalent to `cds.queued(srv).send(event, data)` plus optional timing. See [Background Tasks](#background-tasks).
+
+| API | Description |
+|-----|-------------|
+| `srv.send(event, data)` | Trigger a queued event; for queued services the direct return value is discarded |
+| `srv.schedule(event, data)` | Schedule a task with optional timing |
+| `srv.schedule.task(event, data)` | Schedule a *singleton* task identified by name |
+| `srv.unschedule.task(name)` | Remove a previously scheduled singleton task |
+
+The signatures above show the Node.js shape. Java has equivalent APIs; documentation to follow.
+
+#### Acting on the Outcome
+
+Because queued calls return after the *message is stored* — not after the remote operation completes — you can't use the return value of `send()` or `run()` to react to success or failure. Register a callback handler on `#succeeded` or `#failed` instead.
+
+[Learn more about callbacks.](#callbacks-saga-patterns){.learn-more}
+
+#### Manual Processing
+
+In single-tenancy, the background runner starts on application startup and processes pending messages automatically. In multitenancy, the central runner periodically checks markers and triggers processing.
+
+To trigger processing manually — for example, from a startup hook or admin endpoint:
+
+::: code-group
+```js [Node.js]
+// Flush a specific queue
+const xflights = await cds.connect.to('xflights')
+await cds.flush(xflights.name)
+
+// Flush all queues
+await cds.flush()
+```
+:::
+
+> [!note] Node.js only
+> `cds.flush()` is currently a Node.js API. You rarely need it: both stacks have built-in recovery mechanisms that pick up pending messages automatically.
+
+### By Configuration
+
+#### Auto-Outboxed Services
+
+The following services are outboxed by default — you don't need to wrap or configure them:
+
+| Service | Description |
+|---------|-------------|
+| `cds.MessagingService` | All messaging services |
+| `cds.AuditLogService` | Audit log events |
+
+This ensures that messaging and audit log events are sent reliably and never lost because of transaction rollbacks.
+
+[Learn more about auto-outboxed services in Node.js.](../../node.js/event-queues#queueing-a-service){.learn-more}
+[Learn more about the outbox in Java.](../../java/event-queues#default-outbox-services){.learn-more}
+
+#### Outboxing a Remote Service
+
+You can outbox any *outbound* service through configuration without changing code. That is useful when you want to switch a remote integration to durable asynchronous processing centrally — every call from your handlers is then queued automatically.
+
+::: code-group
+```json [Node.js — package.json]
+{
+ "cds": {
+ "requires": {
+ "xflights": {
+ "kind": "odata",
+ "outboxed": true
+ }
+ }
+ }
+}
+```
+```yaml [Java — application.yaml]
+cds:
+ outbox:
+ services:
+ XFlightsOutbox:
+ maxAttempts: 10
+```
+:::
+
+#### Configuring the Queue
+
+The persistent queue is enabled by default. Messages are stored in a database table within the current transaction.
+
+> [!note] Defaults differ between stacks
+> Node.js enables the **persistent** queue for every queued service by default. Java enables the persistent outbox for `cds.MessagingService` and `cds.AuditLogService` only; other services use the in-memory outbox unless you opt them in via `cds.requires.outbox.kind: persistent-outbox`. The Java configuration sample below already does that.
+
+::: code-group
+```json [Node.js — package.json]
+{
+ "cds": {
+ "requires": {
+ "scheduling": {},
+ "queue": {
+ "maxAttempts": 20,
+ "chunkSize": 10
+ }
+ }
+ }
+}
+```
+```yaml [Java — application.yaml]
+cds:
+ outbox:
+ services:
+ DefaultOutboxOrdered:
+ maxAttempts: 10
+ ordered: true
+ DefaultOutboxUnordered:
+ maxAttempts: 10
+ ordered: false
+```
+:::
+
+::: details Queue and scheduling options for Node.js
+
+`cds.requires.queue`:
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `maxAttempts` | `20` | Maximum retries before a message becomes a dead letter |
+| `chunkSize` | `10` | Number of messages to process per batch |
+| `storeLastError` | `true` | Store error information of the last failed attempt |
+| `timeout` | `"1h"` | Time after which a `processing` message is considered abandoned |
+
+`cds.requires.scheduling` (multitenancy coordination):
+
+| Option | Description |
+|--------|-------------|
+| `markerInterval` | Grid interval for markers; CAP picks a default that spreads tenant load across the interval |
+| `flushInterval` | Cadence at which the central runner checks for tenants with pending work |
+
+:::
+
+::: details Configuration options for Java
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `maxAttempts` | `10` | Maximum retries before the entry is considered dead |
+| `ordered` | `true` | Process entries in submission order |
+
+:::
+
+#### Disabling the Queue
+
+You can disable event queues globally:
+
+```json
+{
+ "cds": {
+ "requires": {
+ "queue": false
+ }
+ }
+}
+```
+
+Or disable queueing for a specific service:
+
+```json
+{
+ "cds": {
+ "requires": {
+ "messaging": {
+ "outboxed": false
+ }
+ }
+ }
+}
+```
+
+
+## How It Works
+
+### The Data Model
+
+The persistent queue stores its messages in this entity, which CAP adds to your model on build and deploys with your other entities:
+
+```cds
+namespace cds.outbox;
+
+entity Messages {
+ key ID : UUID; // Unique message identifier
+ timestamp : Timestamp; // When the message was queued
+ target : String; // Target service/queue name
+ msg : LargeString; // Serialized event payload
+ attempts : Integer default 0; // Number of processing attempts
+ partition : Integer default 0;
+ lastError : LargeString; // Error from last failed attempt
+ lastAttemptTimestamp : Timestamp; // When last attempt occurred
+ status : String(23); // Current processing status
+ task : String(255); // Task name for named/singleton tasks
+ appid : String(255); // Application ID for shared HDI containers
+}
+```
+
+### Locking and Migration
+
+CAP uses **application-level locking** to coordinate processors across application instances. When a runner picks up a message, it sets the message's `status` to `processing`; other runners skip messages in that state. After processing, the row lock is released; the message is deleted (on success) or rescheduled (on failure) in the processing transaction.
+
+::: warning Migrating across `@sap/cds` major versions
+This guide describes the implementation in `@sap/cds` 10+. Older versions select messages differently:
+
+- **`@sap/cds` 8** does **not** check the `status` column at all.
+- **`@sap/cds` 9** checks `status` but holds a row-level lock for the duration of processing (`legacyLocking: true` is the default in cds 9).
+- **`@sap/cds` 10** uses application-level locking via `status` and releases the row lock after selection.
+
+A rolling upgrade from `@sap/cds` 8 directly to 10 can therefore lead to **double-processing of messages**, because cds 8 instances pick up messages that a cds 10 instance has already marked `processing`. Plan downtime, drain the queue before upgrading, or upgrade through cds 9 first.
+:::
+
+### Scheduling, Processing, Recovery
+
+Behind the scenes, event queues run three independent loops:
+
+1. **Scheduling** — calling `srv.send()`, `srv.emit()`, or `srv.schedule()` on a queued service writes the message to the tenant's queue table within the current transaction. In multitenancy, a *marker* is also written to the provider database, recording that this tenant has pending work.
+2. **Processing** — a tenant-local task runner reads a chunk of messages, dispatches each event in its own transaction, and deletes successful messages. Failed messages are rescheduled with exponentially increasing delay; after `maxAttempts` they become dead letters.
+3. **Recovery** — a central runner periodically polls the provider markers and triggers processing for any tenant with pending work. This recovers from application restarts and tenants that became "cold" without losing messages.
+
+*Markers* contain no business data — only the information that some queue of some tenant needs to be flushed at some point in time.
+
+### Single-Tenancy vs Multi-Tenancy
+
+Event queues work in both single-tenant and multi-tenant deployments. In both cases, processing is triggered immediately after commit; markers are an optimization plus an extra layer of resilience.
+
+[Learn more about multitenancy.](../multitenancy/){.learn-more}
+
+#### Single-Tenancy
+
+Messages are stored in a queue table that resides in the application database. A background runner starts when your application starts and processes messages continuously.
+
+#### Multi-Tenancy
+
+Each tenant has its own database. To avoid having a central runner periodically scan every tenant, the system writes a lightweight *marker* to a central (provider) database whenever messages are queued. On startup the central runner only triggers processing for tenants that actually have pending work, and rechecks periodically as a recovery layer in case a runner crashed before processing completed.
+
+CAP spreads marker timestamps across tenants so that processing doesn't synchronize into bursts — you don't need to configure that.
+
+
+## Working with Event Queues
+
+This section covers what you need to know to operate an event queue in production: how errors are retried, how to manage stuck messages, and how authorization carries over from the original request.
+
+### Inspecting the Queue
+
+To see what's currently queued for a tenant, query `cds.outbox.Messages` directly. The columns most useful for triage are `status`, `attempts`, `target`, `lastError`, and `lastAttemptTimestamp`:
+
+```sql
+SELECT ID, target, status, attempts, lastAttemptTimestamp, lastError
+ FROM cds_outbox_Messages
+ ORDER BY timestamp DESC;
+```
+
+For a managed view with bound *revive* and *delete* actions, expose a CDS service over the same entity — see [Dead Letter Queue](#dead-letter-queue) below. The same projection can be widened (drop the `attempts >= maxAttempts` filter) to inspect *all* pending messages, not just dead letters.
+
+### Error Handling
+
+When processing fails, the system retries the message with exponentially increasing delays.
+After a configurable maximum number of attempts, the message is moved to the dead letter queue.
+
+Some errors are identified as _unrecoverable_ — for example, when a topic is forbidden by the broker.
+These messages are immediately moved to the dead letter queue without further retries.
+
+::: details When is a message picked up next?
+A pending message is *processable* when all three conditions hold:
+
+1. Its scheduled timestamp plus the retry backoff (`attempts × `) is in the past.
+2. Its `attempts` count is less than `maxAttempts`.
+3. Its `status` is not `processing`, or its `processing` status has timed out (`timeout`).
+
+Messages that fail criterion 2 become dead letters. Messages that fail criterion 3 are skipped on this run and become eligible again once the lock times out (recovery from a crashed runner).
+:::
+
+To mark your own errors as unrecoverable in Node.js:
+
+```js
+const error = new Error('Invalid payload')
+error.unrecoverable = true
+throw error
+```
+
+In Java, suppress retries by catching the error and calling `context.setCompleted()`:
+
+```java
+@On(service = "", event = "myEvent")
+void process(OutboxMessageEventContext context) {
+ try {
+ // processing logic
+ } catch (Exception e) {
+ if (isSemanticError(e)) {
+ context.setCompleted(); // remove from queue, no retry
+ } else {
+ throw e; // retry
+ }
+ }
+}
+```
+
+### Dead Letter Queue
+
+Messages that exceed the maximum retry count remain in the `cds.outbox.Messages` database table with their error information intact.
+These entries form the _dead letter queue_ and require manual intervention — either to fix the underlying issue and retry, or to discard the message.
+
+For troubleshooting, inspect `cds.outbox.Messages` and pay special attention to `status`, `attempts`, `lastError`, and `lastAttemptTimestamp`.
+See [*The Data Model*](#the-data-model) for the entity structure.
+
+#### Managing Dead Letters
+
+You can expose a CDS service to manage the dead letter queue with actions to revive or delete entries.
+
+##### 1. Define the Service
+
+::: code-group
+```cds [srv/outbox-dead-letter-queue-service.cds]
+using from '@sap/cds/srv/outbox';
+
+@requires: 'internal-user'
+service OutboxDeadLetterQueueService {
+
+ @readonly
+ entity DeadOutboxMessages as projection on cds.outbox.Messages
+ actions {
+ action revive();
+ action delete();
+ };
+
+}
+```
+:::
+
+::: warning Restrict access
+The dead letter queue contains sensitive data.
+Ensure the service is accessible only to internal users.
+:::
+
+##### 2. Filter for Dead Entries
+
+As `maxAttempts` is configurable, its value cannot be added as a static filter to the projection, but must be applied programmatically.
+
+::: code-group
+```js [Node.js — srv/outbox-dead-letter-queue-service.js]
+const cds = require('@sap/cds')
+
+module.exports = class OutboxDeadLetterQueueService extends cds.ApplicationService {
+ async init() {
+ this.before('READ', 'DeadOutboxMessages', function (req) {
+ const { maxAttempts } = cds.env.requires.queue
+ req.query.where('attempts >= ', maxAttempts)
+ })
+ await super.init()
+ }
+}
+```
+```java [Java — DeadOutboxMessagesHandler.java]
+@Component
+@ServiceName(OutboxDeadLetterQueueService_.CDS_NAME)
+public class DeadOutboxMessagesHandler implements EventHandler {
+
+ private final PersistenceService db;
-The _Outbox Pattern_ is a reliable strategy used in distributed systems to ensure that messages or events are consistently recorded and delivered, even in the face of failures. _Event Queues_ not only apply this pattern to _outbound_ messages, but also to _inbound_ messages and to _internal_ background tasks. So, event queues can be used for four different use cases:
+ public DeadOutboxMessagesHandler(
+ @Qualifier(PersistenceService.DEFAULT_NAME) PersistenceService db) {
+ this.db = db;
+ }
-* **Outbox** → for outbound calls to remote services
-* **Inbox** → for asynchronously handling inbound requests
-* **Background tasks** → e.g., scheduled periodically
-* **Remote Callbacks** → implementing SAGA patterns
+ @Before(entity = DeadOutboxMessages_.CDS_NAME)
+ public void addDeadEntryFilter(CdsReadEventContext context) {
+ Optional outboxFilters = createOutboxFilters(context.getCdsRuntime());
+ outboxFilters.ifPresent(filter -> {
+ CqnSelect modified = copy(context.getCqn(), new Modifier() {
+ @Override
+ public CqnPredicate where(Predicate where) {
+ return filter.and(where);
+ }
+ });
+ context.setCqn(modified);
+ });
+ }
+}
+```
+:::
-The core principle remains the same:
+##### 3. Implement Bound Actions
-Instead of being sent directy to receivers, event messages are persisted in an _Event Queue_ table in the database -- **within the same transaction** as the triggering action, if applicable.
+Entries in the dead letter queue can be _revived_ by resetting the retry counter to zero, or _deleted_ permanently.
-Later on, these event messages are read from the database and actually sent to the receiving services, hence **processed asynchronously** -- with retries, if necessary, so guaranteeing **ultimate resilience**.
+::: code-group
+```js [Node.js — srv/outbox-dead-letter-queue-service.js]
+this.on('revive', 'DeadOutboxMessages', async function (req) {
+ await UPDATE(req.subject).set({ attempts: 0 })
+})
+this.on('delete', 'DeadOutboxMessages', async function (req) {
+ await DELETE.from(req.subject)
+})
+```
+```java [Java]
+@On
+public void reviveOutboxMessage(DeadOutboxMessagesReviveContext context) {
+ CqnAnalyzer analyzer = CqnAnalyzer.create(context.getModel());
+ Map key = analyzer.analyze(context.getCqn()).rootKeys();
+ Messages msg = Messages.create((String) key.get(Messages.ID));
+ msg.setAttempts(0);
+ db.run(Update.entity(Messages_.class).entry(key).data(msg));
+ context.setCompleted();
+}
+@On
+public void deleteOutboxEntry(DeadOutboxMessagesDeleteContext context) {
+ CqnAnalyzer analyzer = CqnAnalyzer.create(context.getModel());
+ Map key = analyzer.analyze(context.getCqn()).rootKeys();
+ db.run(Delete.from(Messages_.class).byId(key.get(Messages.ID)));
+ context.setCompleted();
+}
+```
+:::
+[Learn more about the dead letter queue in Node.js.](../../node.js/event-queues#dead-letter-queue){.learn-more}
+[Learn more about the dead letter queue in Java.](../../java/event-queues#dead-letter-queue){.learn-more}
-## Outbox { #outbox }
+### Deferred Principal Propagation
-Regarding the _outbox_, please see the following existing documentation:
-- [Transactional Outbox](../../java/outbox) in CAP Java
-- [Outboxing with `cds.queued`](../../node.js/queue) in CAP Node.js
+When an event is processed asynchronously, the original HTTP request context is no longer available.
+CAP handles this as follows:
+- The **user ID** is stored with the queued message and re-created when the message is processed.
+- **User roles and attributes** are _not_ stored. Asynchronous processing always runs in privileged mode.
+This means queued handlers must not rely on request-time role checks.
+If you need authorization in queued processing, encode the required information in the event payload itself or derive it from persisted business data.
-## Inbox { #inbox }
-Through the _inbox_, inbound messages can be accepted as asynchronous tasks.
-That is, the messaging service persists the message to the database, acknowledges it to the message broker, and schedules its processing.
+## Stack Differences at a Glance
-Simply configure your messaging service for Node.js as cds.requires.messaging.inboxed = true and for CAP Java as cds.messaging.services=[{"name": "messaging-name", "inbox": {"enabled": true}}]
+The two stacks share the concept and the data model, but their APIs and feature sets diverge in a few places. The following table summarizes the differences as of `@sap/cds` 10:
-**Inboxing moves the dead letter queue into your CAP app❗️**
+| Feature | Node.js | Java |
+|---|---|---|
+| Programmatic wrap | `cds.queued(srv)` | `OutboxService.outboxed(svc)` / `AsyncCqnService.of(svc, outbox)` |
+| Default for non-auto-outboxed services | persistent | in-memory |
+| Custom outbox services | through configuration | dedicated API + configuration |
+| `srv.schedule()` (delay / recurrence / cron) | available | equivalent API; documentation to follow |
+| Singleton tasks (`srv.schedule.task` / `srv.unschedule.task`) | available | not available |
+| Callback events `#succeeded` / `#failed` | available | on the roadmap |
+| Manual processing trigger (`cds.flush()`) | available | not needed; both stacks recover automatically |
+| Event versioning for blue/green deployments | not available | available |
+| OpenTelemetry KPI metrics | not available | available |
+| Shared-database isolation pattern | not applicable | available |
-With the inbox, all messages are acknowledged towards the message broker regardless of whether they can be processed or not.
-Hence, failures need to be managed via the dead letter queue built on `cds.outbox.Messages`.
-[Learn more about the dead letter queue in Node.js.](../../node.js/queue#managing-the-dead-letter-queue){.learn-more}
-[Learn more about the dead letter queue in Java.](../../java/outbox#outbox-dead-letter-queue){.learn-more}
+## Next Steps
-Inboxing is especially beneficial in case the message broker does not allow to trigger redelivery and/ or "fix" the message payload.
+For stack-specific APIs, configuration keys, and troubleshooting:
+- [Event Queues in Node.js](../../node.js/event-queues) — `cds.queued`, `cds.unqueued`, `cds.flush`, `srv.schedule` (incl. singleton tasks and `#succeeded` / `#failed` callbacks), queue and scheduling configuration, troubleshooting.
+- [Event Queues in Java](../../java/event-queues) — `OutboxService`, `AsyncCqnService`, custom outbox services, the technical outbox API, error-handling patterns, event versioning for blue/green deployments, and OpenTelemetry observability.
+Most real-world event-queue use comes through messaging or remote services. From here you'll likely want to look at:
-## More to Come
+- [Messaging](messaging) — emitting and consuming events between CAP applications and via brokers; messaging services are auto-outboxed.
+- [CAP-Level Service Integration](../integration/calesi) — consuming remote services as if they were local; outboxing them centrally with `outboxed: true`.
+- [CAP-Level Data Federation](../integration/data-federation) — using `srv.schedule().every()` for polling-based replication from remote services.
-This documentation is not complete yet, or the APIs are not released for general availability.
-Stay tuned to upcoming releases for further updates.
diff --git a/guides/security/data-protection.md b/guides/security/data-protection.md
index 1292969bbc..574b657755 100644
--- a/guides/security/data-protection.md
+++ b/guides/security/data-protection.md
@@ -576,7 +576,7 @@ Design your CDS services exposed to web adapters on need-to-know basis. Be espec
#### CAP Service Runtime
Open transactions are expensive as they bind many resources such as a database connection as well as memory buffers.
-To minimize the amount of time a transaction must be kept open, the CAP runtime offers an [Outbox Service](../../java/outbox) that allows to schedule asynchronous remote calls in the business transaction.
+To minimize the amount of time a transaction must be kept open, the CAP runtime offers an [Outbox Service](../../java/event-queues) that allows to schedule asynchronous remote calls in the business transaction.
Hence, the request time to process a business query, which requires a remote call (such as to an audit log server or messaging broker), is minimized and independent from the response time of the remote service.
::: tip
diff --git a/java/_menu.md b/java/_menu.md
index 9846736087..624c2e5c23 100644
--- a/java/_menu.md
+++ b/java/_menu.md
@@ -16,11 +16,11 @@
## [Indicating Errors](event-handlers/indicating-errors)
## [Request Contexts](event-handlers/request-contexts)
## [ChangeSet Contexts](event-handlers/changeset-contexts)
+# [Event Queues](event-queues)
# [Fiori Drafts](fiori-drafts)
# [Messaging](messaging)
# [Audit Logging](auditlog)
# [Change Tracking](change-tracking)
-# [Transactional Outbox](outbox)
# [Multitenancy](multitenancy)
# [Security](security)
# [Spring Boot Integration](spring-boot-integration)
diff --git a/java/auditlog.md b/java/auditlog.md
index 68c85e338d..604250c563 100644
--- a/java/auditlog.md
+++ b/java/auditlog.md
@@ -30,7 +30,7 @@ The following events can be emitted with the [AuditLogService](https://javadoc.i
- [Configuration changes](#config-change)
- [Security events](#security-event)
-AuditLog events typically are bound to business transactions. In order to handle the events transactionally and also to decouple the request from outbound calls to a consumer, for example a central audit log service, the AuditLog service leverages the [outbox](./outbox) service internally which allows [deferred](#deferred) sending of events.
+AuditLog events typically are bound to business transactions. In order to handle the events transactionally and also to decouple the request from outbound calls to a consumer, for example a central audit log service, the AuditLog service leverages the [outbox](./event-queues) service internally which allows [deferred](#deferred) sending of events.
### Use AuditLogService
@@ -102,13 +102,13 @@ auditLogService.logSecurityEvent(action, data);
### Deferred AuditLog Events { #deferred}
-Instead of processing the audit log events synchronously in the [audit log handler](#auditlog-handlers), the `AuditLogService` can store the event in the [outbox](./outbox). This is done in the *same* transaction of the business request. Hence, a cancelled business transaction will not send any audit log events that are bound to it. To gain fine-grained control, for example to isolate a specific event from the current transaction, you may refine the transaction scope. See [ChangeSetContext API](./event-handlers/changeset-contexts#defining-changeset-contexts) for more information.
+Instead of processing the audit log events synchronously in the [audit log handler](#auditlog-handlers), the `AuditLogService` can store the event in the [outbox](./event-queues). This is done in the *same* transaction of the business request. Hence, a cancelled business transaction will not send any audit log events that are bound to it. To gain fine-grained control, for example to isolate a specific event from the current transaction, you may refine the transaction scope. See [ChangeSetContext API](./event-handlers/changeset-contexts#defining-changeset-contexts) for more information.
As the stored events are processed asynchronously, the business request is also decoupled from the audit log handler which typically sends the events synchronously to a central audit log service. This improves resilience and performance.
-By default, the outbox comes in an [in-memory](./outbox#in-memory) flavour which has the drawback that it can't guarantee that the all events are processed after the transaction has been successfully closed.
+By default, the outbox comes in an [in-memory](./event-queues#persistent-vs-in-memory-outbox) flavour which has the drawback that it can't guarantee that the all events are processed after the transaction has been successfully closed.
-To close this gap, a sophisticated [persistent outbox](./outbox#persistent) service can be configured.
+To close this gap, a sophisticated [persistent outbox](./event-queues#default-outbox-services) service can be configured.
By default, not all events are send asynchronously via (persistent) outbox.
* [Security events](#security-event) are always send synchronously.
diff --git a/java/event-queues.md b/java/event-queues.md
new file mode 100644
index 0000000000..3afde22a0c
--- /dev/null
+++ b/java/event-queues.md
@@ -0,0 +1,415 @@
+---
+synopsis: >
+ Java APIs and configuration for CAP's Transactional Event Queues — `OutboxService`, `AsyncCqnService`, custom outbox services, error handling, event versioning, and observability.
+status: released
+---
+
+# Event Queues in Java
+
+
+For concepts, use cases, and guarantees, see the [Transactional Event Queues](../guides/events/event-queues) guide. This page covers the Java-specific APIs and configuration on top of that.
+
+In Java, event queues are exposed as **outbox services**. The runtime ships two default outboxes — `DefaultOutboxOrdered` and `DefaultOutboxUnordered` — and you can register custom outbox services for advanced isolation, scaling, or shared-database scenarios.
+
+[[toc]]
+
+
+## Programmatic API
+
+> [!warning] In-memory by default
+> Custom services wrapped through this API queue messages **in memory** by default. To make them durable across application restarts, enable the persistent outbox in your configuration — see [*Persistent vs. In-Memory Outbox*](#persistent-vs-in-memory-outbox).
+
+### Outboxing a Service
+
+Wrap any CAP service with outbox handling. Events triggered on the returned wrapper are stored in the outbox first and executed asynchronously after commit. Relevant information from the `RequestContext` is stored with the event data; the user context is downgraded to a system user context.
+
+```java
+OutboxService myCustomOutbox = ...;
+CqnService remoteS4 = ...;
+CqnService outboxedS4 = myCustomOutbox.outboxed(remoteS4);
+```
+
+If a method on the outboxed service has a return value, it returns `null` — the call is asynchronous. To make this explicit at the type level, use the variant that wraps the service with an asynchronous-suited API:
+
+```java
+OutboxService myCustomOutbox = ...;
+CqnService remoteS4 = ...;
+AsyncCqnService outboxedS4 = myCustomOutbox.outboxed(remoteS4, AsyncCqnService.class);
+```
+
+`AsyncCqnService.of()` is a convenience for the common case:
+
+```java
+OutboxService myCustomOutbox = ...;
+CqnService remoteS4 = ...;
+AsyncCqnService outboxedS4 = AsyncCqnService.of(remoteS4, myCustomOutbox);
+```
+
+The outboxed service is thread-safe and can be cached. Any service that implements the `Service` interface can be outboxed, and each call is asynchronously executed if the API method internally calls `Service.emit(EventContext)`.
+
+To recover the synchronous service from a wrapped one:
+
+```java
+CqnService synchronous = OutboxService.unboxed(outboxedS4);
+```
+
+::: tip Custom asynchronous-suited APIs
+When defining your own asynchronous-suited interface, it must provide the same method signatures as the interface of the outboxed service, except for the return types — those should be `void`.
+:::
+
+::: warning Java Proxy
+A service wrapped by an outbox is a [Java Proxy](https://docs.oracle.com/javase/8/docs/technotes/guides/reflection/proxy.html). It only implements the *interfaces* of the underlying object — you can't cast an outboxed service proxy back to its concrete implementation class.
+:::
+
+
+### Technical Outbox API
+
+The technical API outboxes custom messages for arbitrary events or processing logic. The `OutboxMessage` instance is serialized to JSON and stored in the database, so all data must be JSON-serializable.
+
+```java
+OutboxService outboxService = runtime.getServiceCatalog()
+ .getService(OutboxService.class, "");
+
+OutboxMessage message = OutboxMessage.create();
+message.setParams(Map.of("name", "John", "lastname", "Doe"));
+
+outboxService.submit("myEvent", message);
+```
+
+Register an `@On` handler on the outbox service to perform the processing logic when the message is published:
+
+```java
+@On(service = "", event = "myEvent")
+void processMyEvent(OutboxMessageEventContext context) {
+ OutboxMessage message = context.getMessage();
+ Map params = message.getParams();
+ String name = (String) params.get("name");
+ String lastname = (String) params.get("lastname");
+
+ // Perform processing logic for myEvent
+
+ context.setCompleted();
+}
+```
+
+The handler must complete the context after executing the processing logic.
+
+[Learn more about event handlers.](./event-handlers/){.learn-more}
+
+::: tip Customizing outbox entries
+The outbox has no information about the structure or data types being serialized. If your custom messages use non-default data types — or you need extra context properties — register `@Before` and `@On` handlers to customize serialization and deserialization. *(This isn't required for CDS-model-based services.)*
+
+```java [srv/src/main/java/com/myapp/CustomOutboxHandler.java]
+@Component
+@ServiceName(value = "*", type = OutboxService.class)
+public class CustomOutboxHandler implements EventHandler {
+
+ @On
+ void publishedByOutbox(OutboxMessageEventContext context) {
+ // Restore custom values from context only
+ if (Boolean.FALSE.equals(context.getIsInbound())) {
+ return;
+ }
+
+ // custom deserialization logic
+ Long date = (Long) context.getMessage().getParams().get("orderDate");
+ context.getMessage().getParams().put("orderDate", Instant.ofEpochSecond(date));
+ }
+
+ @Before(event = "*")
+ void prepareOutboxMessage(OutboxMessageEventContext context) {
+ // prepare outbox message for storage only
+ if (Boolean.TRUE.equals(context.getIsInbound())) {
+ return;
+ }
+
+ // custom serialization logic
+ Instant date = (Instant) context.getMessage().getParams().get("orderDate");
+ context.getMessage().getParams().put("orderDate", new Long(date.getEpochSecond()));
+ }
+}
+```
+
+**Don't complete the context in either of those handlers**, otherwise the next handler in the chain isn't called and processing breaks.
+:::
+
+
+### Error-Handling Patterns
+
+By default the outbox retries publishing a message on error until it reaches `maxAttempts`. This makes applications resilient against unavailability of external systems.
+
+Some errors aren't worth retrying — for example, a `400 Bad Request` from a downstream service indicates a *semantic* error that the same payload will reproduce on every attempt. Wrap the processing in a try/catch and call `context.setCompleted()` to remove the message from the queue without further retries:
+
+```java
+@On(service = "", event = "myEvent")
+void processMyEvent(OutboxMessageEventContext context) {
+ try {
+ // Perform processing logic for myEvent
+ } catch (Exception e) {
+ if (isUnrecoverableSemanticError(e)) {
+ // Perform application-specific counter-measures
+ context.setCompleted(); // indicate message deletion to outbox
+ } else {
+ throw e; // indicate error to outbox
+ }
+ }
+}
+```
+
+If the original processing logic isn't yours and you need to wrap its error handling, use `EventContext.proceed()`:
+
+```java
+@On(service = OutboxService.PERSISTENT_ORDERED_NAME, event = AuditLogService.DEFAULT_NAME)
+void handleAuditLogProcessingErrors(OutboxMessageEventContext context) {
+ try {
+ context.proceed(); // wrap default logic
+ } catch (Exception e) {
+ if (isUnrecoverableSemanticError(e)) {
+ // Perform application-specific counter-measures
+ context.setCompleted();
+ } else {
+ throw e;
+ }
+ }
+}
+```
+
+[Learn more about `EventContext.proceed()`.](./event-handlers/#proceed-on){.learn-more}
+
+
+### Scheduling
+
+An equivalent of the Node.js `srv.schedule()` API exists in Java; documentation is on its way. For the parity status, see [*Stack Differences at a Glance*](../guides/events/event-queues#stack-differences-at-a-glance) in the common guide.
+
+
+## Configuration
+
+### Default Outbox Services
+
+CAP Java ships two default outbox services:
+
+- **`DefaultOutboxOrdered`** — used by [messaging services](messaging) by default. Processes entries in submission order.
+- **`DefaultOutboxUnordered`** — used by the [AuditLog service](auditlog) by default. May process entries in parallel across application instances.
+
+The configuration of both can be overridden in *application.yaml*:
+
+::: code-group
+```yaml [srv/src/main/resources/application.yaml]
+cds:
+ outbox:
+ services:
+ DefaultOutboxOrdered:
+ maxAttempts: 10
+ # ordered: true
+ DefaultOutboxUnordered:
+ maxAttempts: 10
+ # ordered: false
+```
+:::
+
+| Option | Default | Description |
+|---|---|---|
+| `maxAttempts` | `10` | Number of unsuccessful emits until the message is ignored. It still remains in the database. |
+| `ordered` | `true` | Process entries in submission order. Cannot be changed for the two default outboxes. |
+
+The persistent outbox stores the last error in the `lastError` element of `cds.outbox.Messages`.
+
+
+### Persistent vs. In-Memory Outbox
+
+CAP Java's default behavior is the **in-memory outbox** for services other than messaging and audit log: messages are kept in memory and emitted when the current transaction succeeds. To enable persistent processing across the runtime, add the `outbox` service of kind `persistent-outbox` to `cds.requires`:
+
+```jsonc
+{
+ "cds": {
+ "requires": {
+ "outbox": {
+ "kind": "persistent-outbox"
+ }
+ }
+ }
+}
+```
+
+::: warning Schema migration required
+Adding the persistent outbox enhances your CDS model. Migrate the database schema of all tenants after enabling it.
+:::
+
+For multitenancy scenarios, apply the same configuration in the MTX sidecar service and ensure the base model in all tenants is updated.
+
+::: info Add the outbox to your base model
+Alternatively, add `using from '@sap/cds/srv/outbox';` to your base model. With this approach, you update tenant models after deployment but don't have to update the MTX sidecar.
+:::
+
+
+### Custom Outbox Services
+
+Configure custom persistent outboxes in *application.yaml*:
+
+::: code-group
+```yaml [srv/src/main/resources/application.yaml]
+cds:
+ outbox:
+ services:
+ MyCustomOutbox:
+ maxAttempts: 5
+ MyOtherCustomOutbox:
+ maxAttempts: 10
+```
+:::
+
+Access them either via the service catalog:
+
+```java
+OutboxService myCustomOutbox = cdsRuntime.getServiceCatalog()
+ .getService(OutboxService.class, "MyCustomOutbox");
+```
+
+or by Spring injection:
+
+```java
+@Component
+public class MySpringComponent {
+ private final OutboxService myCustomOutbox;
+
+ public MySpringComponent(@Qualifier("MyCustomOutbox") OutboxService myCustomOutbox) {
+ this.myCustomOutbox = myCustomOutbox;
+ }
+}
+```
+
+::: warning Removing a custom outbox
+Before removing a custom outbox from the configuration, ensure no unprocessed entries remain in `cds.outbox.Messages` for it. Removing the outbox configuration does not delete the entries — they remain in the table and aren't processed anymore.
+:::
+
+
+### Outbox for Shared Databases
+
+CAP Java does not yet support microservices with a shared database out of the box: the two static-named default outboxes (`DefaultOutboxOrdered`, `DefaultOutboxUnordered`) would be shared across all services and introduce conflicts.
+
+A manual workaround uses isolated custom outboxes with service-specific names:
+
+#### 1. Deactivate the Default Outboxes and Create Service-Specific Ones
+
+```yaml
+cds:
+ outbox:
+ services:
+ # deactivate default outboxes
+ DefaultOutboxUnordered.enabled: false
+ DefaultOutboxOrdered.enabled: false
+ # custom outboxes with unique names
+ Service1CustomOutboxOrdered:
+ maxAttempts: 10
+ storeLastError: true
+ ordered: true
+ Service1CustomOutboxUnordered:
+ maxAttempts: 10
+ storeLastError: true
+ ordered: false
+```
+
+#### 2. Adapt Audit Log Configuration
+
+The default audit-log outbox is `DefaultOutboxUnordered`. Point it at the new custom outbox:
+
+```yaml
+cds:
+ auditlog:
+ outbox.name: Service1CustomOutboxUnordered
+```
+
+#### 3. Adapt Messaging Configuration
+
+For *each* messaging service in the application, point it at the new ordered outbox:
+
+```yaml
+cds:
+ messaging:
+ services:
+ MessagingService1:
+ outbox.name: Service1CustomOutboxOrdered
+ MessagingService2:
+ outbox.name: Service1CustomOutboxOrdered
+```
+
+::: tip Important
+Both deactivating the defaults *and* using unique outbox namespaces are required to achieve service isolation in a shared-DB scenario.
+:::
+
+
+### Event Versions for Blue/Green Deployments
+
+In blue/green scenarios, outbox collectors of an older deployment may not be able to process events emitted by a newer deployment. Configure each deployment with an *event version* so older collectors skip newer events:
+
+[`cds.environment.deployment.version: 2`](./developing-applications/properties#cds-environment-deployment-version)
+
+::: warning Ascending versions only
+Configured deployment versions must increase. Messages are processed by an outbox collector only if the event version is less than or equal to the deployment version.
+:::
+
+To automate versioning from the Maven app version, enable resource filtering in *srv/pom.xml*:
+
+::: code-group
+```xml [srv/pom.xml]
+
+ ...
+
+
+ src/main/resources
+ true
+
+
+ ...
+```
+:::
+
+Then use the `${project.version}` placeholder:
+
+[`cds.environment.deployment.version: ${project.version}`](./developing-applications/properties#cds-environment-deployment-version)
+
+A startup log entry shows the configured version:
+
+```bash
+2024-12-19T11:21:33.253+01:00 INFO 3420 --- [main] cds.services.impl.utils.BuildInfo : application.deployment.version: 1.0.0-SNAPSHOT
+```
+
+To opt a specific custom outbox out of the version check entirely, set [`cds.outbox.services.MyCustomOutbox.checkVersion: false`](./developing-applications/properties#cds-outbox-services--checkVersion).
+
+
+## Observability via OpenTelemetry
+
+The transactional outbox integrates with [OpenTelemetry](./operating-applications/observability#open-telemetry) for telemetry data. In addition to the spans described in the [observability chapter](./operating-applications/observability), the outbox logs the following KPIs:
+
+| KPI Name | Description | KPI Type |
+|--------------------------------------------|--------------------------------------------------------------------------------------------------------|----------|
+| `com.sap.cds.outbox.coldEntries` | Number of entries that could not be delivered after repeated attempts and will not be retried anymore. | Gauge |
+| `com.sap.cds.outbox.remainingEntries` | Number of entries pending for delivery. | Gauge |
+| `com.sap.cds.outbox.maxStorageTimeSeconds` | Maximum time in seconds an entry has been residing in the outbox. | Gauge |
+| `com.sap.cds.outbox.medStorageTimeSeconds` | Median time in seconds of an entry stored in the outbox. | Gauge |
+| `com.sap.cds.outbox.minStorageTimeSeconds` | Minimal time in seconds an entry has been stored in the outbox. | Gauge |
+| `com.sap.cds.outbox.incomingMessages` | Number of incoming messages of the outbox. | Counter |
+| `com.sap.cds.outbox.outgoingMessages` | Number of outgoing messages of the outbox. | Counter |
+
+KPIs are logged per microservice instance (in case of horizontal scaling), outbox, and tenant.
+
+
+## Dead Letter Queue
+
+The dead-letter queue lifecycle (define service → filter for dead entries → bound revive/delete actions) is the same shape across both stacks; see [*Dead Letter Queue*](../guides/events/event-queues#dead-letter-queue) in the common guide for the full flow with code in both Node.js and Java.
+
+::: warning Changing `maxAttempts` between deployments
+You can increase `cds.outbox.services..maxAttempts` between deployments. Older entries that had reached the previous maximum will be retried automatically after the new deployment — if the dead letter queue is large, this can cause unintended load on the system.
+:::
+
+::: tip Use paging
+Avoid reading all outbox entries at once when entries with large request payloads are present. Prefer `READ` queries with paging.
+:::
+
+---
+
+Working in Node.js? See [Event Queues in Node.js](../node.js/event-queues).
diff --git a/java/messaging.md b/java/messaging.md
index 3d1ff977df..0f101c9107 100644
--- a/java/messaging.md
+++ b/java/messaging.md
@@ -85,9 +85,9 @@ As shown in the example, there are two flavors of sending messages with the mess
In section [CDS-Declared Events](#cds-declared-events), we show how to declare events in CDS models and by this let CAP generate EventContext interfaces especially tailored for the defined payload, that allows type safe access to the payload.
::: tip Using an outbox
-The messages are sent once the transaction is successful. Per default, an in-memory outbox is used, but there's also support for a [persistent outbox](./outbox#persistent).
+The messages are sent once the transaction is successful. Per default, an in-memory outbox is used, but there's also support for a [persistent outbox](./event-queues#default-outbox-services).
-You can configure a [custom outbox](./outbox#custom-outboxes) for a messaging service by setting the property
+You can configure a [custom outbox](./event-queues#custom-outbox-services) for a messaging service by setting the property
`cds.messaging.services..outbox.name` to the name of the custom outbox. This specifically makes sense when [using multiple channels](../guides/events/messaging#using-multiple-channels).
:::
diff --git a/java/outbox.md b/java/outbox.md
deleted file mode 100644
index 0cba54d407..0000000000
--- a/java/outbox.md
+++ /dev/null
@@ -1,576 +0,0 @@
----
-synopsis: >
- Find here information about the Outbox service in CAP Java.
-status: released
----
-
-# Transactional Outbox
-
-
-{{ $frontmatter.synopsis }}
-
-## Concepts
-
-Usually the emit of messages should be delayed until the main transaction succeeded, otherwise recipients also receive messages in case of a rollback.
-To solve this problem, a transactional outbox can be used to defer the emit of messages until the success of the current transaction.
-
-The outbox is typically not used directly, but rather through the [messaging service](../java/messaging), the [AuditLog service](../java/auditlog) or to [outbox CAP service events](#outboxing-cap-service-events).
-
-## In-Memory Outbox (Default) { #in-memory}
-
-The in-memory outbox is used per default and the messages are emitted when the current transaction is successful. Until then, messages are kept in memory.
-
-
-## Persistent Outbox { #persistent}
-
-The persistent outbox requires a persistence layer to persist the messages before emitting them. Here, the to-be-emitted message is stored in a database table first. The same database transaction is used as for other operations, therefore transactional consistency is guaranteed.
-
-Once the transaction succeeds, the messages are read from the database table and are emitted.
-
-- 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:
-
-- `DefaultOutboxOrdered` - is used by default by [messaging services](../java/messaging)
-- `DefaultOutboxUnordered` - is used by default by the [AuditLog service](../java/auditlog)
-
-The default configuration for both outboxes 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`.
-
-### Configuring Custom Outboxes { #custom-outboxes}
-
-Custom persistent outboxes can be configured using the `cds.outbox.services` section, for example in the _application.yaml_:
-::: code-group
-```yaml [srv/src/main/resources/application.yaml]
-cds:
- outbox:
- services:
- MyCustomOutbox:
- maxAttempts: 5
- MyOtherCustomOutbox:
- maxAttempts: 10
-```
-:::
-Afterward you can access the outbox instances from the service catalog:
-
-```java
-OutboxService myCustomOutbox = cdsRuntime.getServiceCatalog().getService(OutboxService.class, "MyCustomOutbox");
-OutboxService myOtherCustomOutbox = cdsRuntime.getServiceCatalog().getService(OutboxService.class, "MyOtherCustomOutbox");
-```
-
-Alternatively it's possible to inject them into a Spring component:
-
-```java
-@Component
-public class MySpringComponent {
- private final OutboxService myCustomOutbox;
-
- public MySpringComponent(@Qualifier("MyCustomOutbox") OutboxService myCustomOutbox) {
- this.myCustomOutbox = myCustomOutbox;
- }
-}
-```
-
-::: warning When removing a custom outbox ...
-... it must be ensured that there are no unprocessed entries left.
-
-Removing a custom outbox from the `cds.outbox.services` section doesn't remove the
-entries from the `cds.outbox.Messages` table. The entries remain in the `cds.outbox.Messages` table and aren't
-processed anymore.
-
-:::
-
-### Outbox Event Versions
-
-In scenarios with multiple deployment versions (blue/green), situations may arise in which the outbox collectors of the older deployment cannot process the events generated by a newer deployment. In this case, the event can get stuck in the outbox, with all the resulting problems.
-
-To avoid this problem, you can configure the outbox to use an event version that prevents the outbox collectors from using the newer events. For this purpose, you can set the parameter [cds.environment.deployment.version: 2](../java/developing-applications/properties#cds-environment-deployment-version).
-
-::: warning Ascending Versions
-The configured deployment versions must be in ascending order. The messages are only processed by the outbox collector if the event version is less than or equal to the deployment version.
-:::
-
-To make things easier, you can automate versioning by using the Maven app version. This requires you to increment the version for each new deployment.
-
-To do this, the Maven `resource.filtering` configuration in the `srv/pom.xml` must be activated as follows, so that the app version placeholder `${project.version}` can be used in [cds.environment.deployment.version: ${project.version}](../java/developing-applications/properties#cds-environment-deployment-version).
-
-::: code-group
-```xml [srv/pom.xml]
-
- ...
-
-
- src/main/resources
- true
-
-
- ...
-```
-:::
-
-To be sure that the deployment version has been set correctly, you can find a log entry at startup that shows the configured version:
-
-```bash
-2024-12-19T11:21:33.253+01:00 INFO 3420 --- [main] cds.services.impl.utils.BuildInfo : application.deployment.version: 1.0.0-SNAPSHOT
-```
-
-And finally, if for some reason you don't want to use a version check for a particular outbox collector, you can switch it off via the outbox configuration [cds.outbox.services.MyCustomOutbox.checkVersion: false](../java/developing-applications/properties#cds-outbox-services--checkVersion).
-
-### Outbox for Shared Databases
-
-Currently, CAP Java does not yet support microservices with shared database out of the box, as this can lead to unexpected behavior when different isolated services use the same outboxes.
-Since CAP automatically creates two outboxes with a static name — **DefaultOutboxOrdered** and **DefaultOutboxUnordered** — these would be shared across all services which introduces conflicts.
-
-To avoid this, you can apply a manual workaround as follows:
-
- 1. Customize the outbox configuration and isolating them via distinct namespaces for each service.
- 2. Adapt the Audit Log outbox configuration.
- 3. Adapt the messaging outbox configuration per service.
-
- These steps are described in the following sections.
-
-#### Deactivate Default Outboxes
-
-First, deactivate the two default outboxes and create custom outboxes with configurations tailored to your needs.
-
-```yaml
-cds:
- outbox:
- services:
- # deactivate default outboxes
- DefaultOutboxUnordered.enabled: false
- DefaultOutboxOrdered.enabled: false
- # custom outboxes with unique names
- Service1CustomOutboxOrdered:
- maxAttempts: 10
- storeLastError: true
- ordered: true
- Service1CustomOutboxUnordered:
- maxAttempts: 10
- storeLastError: true
- ordered: false
-
-```
-
-#### Adapt Audit Log Configuration
-
-The **DefaultOutboxUnordered** outbox is automatically used for audit logging. Therefore, you must update the audit log configuration to point to the custom one.
-
-```yaml
-cds:
- ...
- auditlog:
- outbox.name: Service1CustomOutboxUnordered
-```
-
-#### Adapt Messaging Configuration
-
-Next, adapt the messaging configuration of **every** messaging service in the application so that they use the custom-defined outboxes.
-
-```yaml
-cds:
- messaging:
- services:
- MessagingService1:
- outbox.name: Service1CustomOutboxOrdered
- MessagingService2:
- outbox.name: Service1CustomOutboxOrdered
-```
-
-
-::: tip Important Note
-It is crucial to **deactivate** the default outboxes, and ensure **unique outbox namespaces** in order to achieve proper isolation between services in a shared DB scenario.
-:::
-
-
-## Outboxing CAP Service Events
-
-Outbox services support outboxing of arbitrary CAP services. A typical use case is to outbox remote OData
-service calls, but also calls to other CAP services can be decoupled from the business logic flow.
-
-The API `OutboxService.outboxed(Service)` is used to wrap services with outbox handling. Events triggered
-on the returned wrapper are stored in the outbox first, and executed asynchronously. Relevant information from
-the `RequestContext` is stored with the event data, however the user context is downgraded to a system user context.
-
-The following example shows you how to outbox a service:
-
-```java
-OutboxService myCustomOutbox = ...;
-CqnService remoteS4 = ...;
-CqnService outboxedS4 = myCustomOutbox.outboxed(remoteS4);
-```
-
-If a method on the outboxed service has a return value, it will always return `null` since it's executed asynchronously. A common example for this are the `CqnService.run(...)` methods.
-To improve this the API `OutboxService.outboxed(Service, Class)` can be used, which wraps a service with an asynchronous suited API while outboxing it.
-This can be used together with the interface `AsyncCqnService` to outbox remote OData services:
-
-```java
-OutboxService myCustomOutbox = ...;
-CqnService remoteS4 = ...;
-AsyncCqnService outboxedS4 = myCustomOutbox.outboxed(remoteS4, AsyncCqnService.class);
-```
-
-The method `AsyncCqnService.of()` can be used alternatively to achieve the same for CqnServices:
-
-```java
-OutboxService myCustomOutbox = ...;
-CqnService remoteS4 = ...;
-AsyncCqnService outboxedS4 = AsyncCqnService.of(remoteS4, myCustomOutbox);
-```
-
-::: tip Custom asynchronous suited API
-When defining your own custom asynchronous suited API, the interface must provide the same method signatures as the interface of the outboxed service, except for the return types which should be `void`.
-:::
-
-The outboxed service is thread-safe and can be cached.
-Any service that implements the `Service` interface can be outboxed.
-Each call to the outboxed service is asynchronously executed, if the API method internally calls the method `Service.emit(EventContext)`.
-
-A service wrapped by an outbox can be unboxed by calling the API `OutboxService.unboxed(Service)`. Method calls to the unboxed
-service are executed synchronously without storing the event in an outbox.
-
-::: warning Java Proxy
-A service wrapped by an outbox is a [Java Proxy](https://docs.oracle.com/javase/8/docs/technotes/guides/reflection/proxy.html). Such a proxy only implements the _interfaces_ of the object that it's wrapping. This means an outboxed service proxy can't be casted to the class implementing the underlying service object.
-:::
-
-::: tip Custom outbox for scaling
-The default outbox services can be used for outboxing arbitrary CAP services. If you detect a scaling issue,
-you can define custom outboxes that can be used for outboxing.
-:::
-
-## Technical Outbox API { #technical-outbox-api }
-
-Outbox services provide the technical API `OutboxService.submit(String, OutboxMessage)` that can be used to outbox custom messages for an arbitrary event or processing logic.
-When submitting a custom message, an `OutboxMessage` that can optionally contain parameters for the event needs to be provided.
-As the `OutboxMessage` instance is serialized and stored in the database, all data provided in that message
-must be serializable and deserializable to/from JSON. The following example shows the submission of a custom message to an outbox:
-
-```java
-OutboxService outboxService = runtime.getServiceCatalog().getService(OutboxService.class, "");
-
-OutboxMessage message = OutboxMessage.create();
-message.setParams(Map.of("name", "John", "lastname", "Doe"));
-
-outboxService.submit("myEvent", message);
-```
-
-A handler for the custom message must be registered on the outbox service. This handler performs the processing logic when the message is published by the outbox:
-
-```java
-@On(service = "", event = "myEvent")
-void processMyEvent(OutboxMessageEventContext context) {
- OutboxMessage message = context.getMessage();
- Map params = message.getParams();
- String name = (String) param.get("name");
- String lastname = (String) param.get("lastname");
-
- // Perform processing logic for myEvent
-
- context.setCompleted();
-}
-```
-
-You must ensure that the handler is completing the context, after executing the processing logic.
-
-[Learn more about event handlers.](./event-handlers/){.learn-more}
-
-::: tip Customizing Outbox Entries
-
-The outbox has no information regarding the structure and the data types that
-shall be serialized and deserialized to and from the outbox.
-
-Special handling is needed to avoid serialization and deserialization errors in custom outbox handlers if custom data types are used, **or** if additional context properties are required. _Special handling isn't required for CDS model-based services._
-
-```java [srv/src/main/java/com/myapp/CustomOutboxHandler.java]
-@Component
-@ServiceName(value = "*", type = OutboxService.class)
-public class CustomOutboxHandler implements EventHandler {
-
- @On
- void publishedByOutbox(OutboxMessageEventContext context) {
- // Restore custom values from context only
- if (Boolean.FALSE.equals(context.getIsInbound())) {
- return;
- }
-
- // custom deserialization logic
- Long date = (Long) context.getMessage().getParams().get("orderDate");
- context.getMessage().getParams().put("orderDate", Instant.ofEpochSecond(date));
- }
-
- @Before(event = "*")
- void prepareOutboxMessage(OutboxMessageEventContext context) {
- // prepare outbox message for storage only
- if (Boolean.TRUE.equals(context.getIsInbound())) {
- return;
- }
-
- // custom serialization logic
- Instant date = (Instant) context.getMessage().getParams().get("orderDate");
- context.getMessage().getParams().put("orderDate", new Long(date.getEpochSecond()));
- }
-}
-```
-
-**Don't complete the context in any of those two handlers, otherwise other
-handlers aren't called and functionality is broken.**
-
-:::
-
-## Handling Outbox Errors { #handling-outbox-errors }
-
-The outbox by default retries publishing a message, if an error occurs during processing, until the message has reached the maximum number of attempts.
-This behavior makes applications resilient against unavailability of external systems, which is a typical use case for outbox message processing.
-
-However, there might also be situations in which it is not reasonable to retry publishing a message.
-For example, when the processed message causes a semantic error - typically due to a `400 Bad request` - on the external system.
-Outbox messages causing such errors should be removed from the outbox message table before reaching the maximum number of retry attempts and instead application-specific
-counter-measures should be taken to correct the semantic error or ignore the message altogether.
-
-A simple try-catch block around the message processing can be used to handle errors:
-- If an error should cause a retry, the original exception should be (re)thrown (default behavior).
-- If an error should not cause a retry, the exception should be suppressed and additional steps can be performed.
-
-```java
-@On(service = "", event = "myEvent")
-void processMyEvent(OutboxMessageEventContext context) {
- try {
- // Perform processing logic for myEvent
- } catch (Exception e) {
- if (isUnrecoverableSemanticError(e)) {
- // Perform application-specific counter-measures
- context.setCompleted(); // indicate message deletion to outbox
- } else {
- throw e; // indicate error to outbox
- }
- }
-}
-```
-
-In some situations, the original outbox processing logic is not implemented by you but the processing needs to be extended with additional error handling.
-In that case, wrap the `EventContext.proceed()` method, which executes the underlying processing logic:
-
-```java
-@On(service = OutboxService.PERSISTENT_ORDERED_NAME, event = AuditLogService.DEFAULT_NAME)
-void handleAuditLogProcessingErrors(OutboxMessageEventContext context) {
- try {
- context.proceed(); // wrap default logic
- } catch (Exception e) {
- if (isUnrecoverableSemanticError(e)) {
- // Perform application-specific counter-measures
- context.setCompleted(); // indicate message deletion to outbox
- } else {
- throw e; // indicate error to outbox
- }
- }
-}
-```
-
-[Learn more about `EventContext.proceed()`.](./event-handlers/#proceed-on){.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`.
-
-[Learn more about CDS Properties.](./developing-applications/properties){.learn-more}
-
-Once the maximum number of attempts is exceeded, the corresponding entry is not touched anymore and hence it can be regarded as dead. Dead outbox entries are not deleted automatically. They remain in the database and it's up to the application to take care of the entries. By defining a CDS service, the dead entries can be managed conveniently. Let's have a look, how you can develop a Dead Letter Queue for the transactional outbox.
-
-::: 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.
-
-:::
-
-
-### Define the Service
-
-::: code-group
-
-```cds [srv/outbox-dead-letter-queue-service.cds]
-using from '@sap/cds/srv/outbox';
-
-@requires: 'internal-user'
-service OutboxDeadLetterQueueService {
-
- @readonly
- entity DeadOutboxMessages as projection on cds.outbox.Messages
- actions {
- action revive();
- action delete();
- };
-
-}
-```
-
-:::
-
-The `OutboxDeadLetterQueueService` provides an entity `DeadOutboxMessages` which is a projection on the outbox table `cds.outbox.Messages` that has two bound actions:
-
-- `revive()` sets the number of attempts to `0` such that the outbox entry is going to be processed again.
-- `delete()` deletes the outbox entry from the database.
-
-Filters can be applied as for any other CDS defined entity, for example, to filter for a specific outbox where the outbox name is stored in the field `target` of the entity `cds.outbox.Messages`.
-
-::: warning `OutboxDeadLetterQueueService` for internal users only
-
-It is crucial to make the service `OutboxDeadLetterQueueService` accessible for internal users only as it contains sensitive data that could be exploited for malicious purposes if unauthorized changes are performed.
-
-[Learn more about pseudo roles](../guides/security/cap-users#pseudo-roles){.learn-more}
-
-:::
-
-### Reading Dead Entries
-
-Filtering the dead entries is done by adding an appropriate `where`-clause to all `READ`-queries which matches all outbox message entries that have been retried for the maximum number of times. The following code provides an example handler implementation defining this behavior for the `DeadLetterQueueService`:
-
-```java
-@Component
-@ServiceName(OutboxDeadLetterQueueService_.CDS_NAME)
-public class DeadOutboxMessagesHandler implements EventHandler {
-
- private final PersistenceService db;
-
- public DeadOutboxMessagesHandler(@Qualifier(PersistenceService.DEFAULT_NAME) PersistenceService db) {
- this.db = db;
- }
-
- @Before(entity = DeadOutboxMessages_.CDS_NAME)
- public void addDeadEntryFilter(CdsReadEventContext context) {
- Optional outboxFilters = this.createOutboxFilters(context.getCdsRuntime());
-
- if (outboxFilters.isPresent()) {
- CqnSelect modifiedCqn =
- copy(
- context.getCqn(),
- new Modifier() {
- @Override
- public CqnPredicate where(Predicate where) {
- return outboxFilters.get().and(where);
- }
- });
- context.setCqn(modifiedCqn);
- }
- }
-
- private Optional createOutboxFilters(CdsRuntime runtime) {
- CdsProperties.Outbox outboxConfigs = runtime.getEnvironment().getCdsProperties().getOutbox();
-
- return runtime.getServiceCatalog().getServices(OutboxService.class)
- .map(service -> {
- OutboxServiceConfig config = outboxConfigs.getService(service.getName());
- return CQL.get(Messages.TARGET).eq(service.getName())
- .and(CQL.get(Messages.ATTEMPTS).ge(config.getMaxAttempts()));
- })
- .reduce(Predicate::or);
- }
-}
-```
-
-[Learn more about event handlers.](./event-handlers/){.learn-more}
-
-### Implement Bound Actions
-
-```java
-@Autowired
-@Qualifier(PersistenceService.DEFAULT_NAME)
-private PersistenceService db;
-
-@On
-public void reviveOutboxMessage(DeadOutboxMessagesReviveContext context) {
- CqnAnalyzer analyzer = CqnAnalyzer.create(context.getModel());
- AnalysisResult analysisResult = analyzer.analyze(context.getCqn());
- Map key = analysisResult.rootKeys();
- Messages deadOutboxMessage = Messages.create((String) key.get(Messages.ID));
-
- deadOutboxMessage.setAttempts(0);
-
- this.db.run(Update.entity(Messages_.class).entry(key).data(deadOutboxMessage));
- context.setCompleted();
-}
-
-@On
-public void deleteOutboxEntry(DeadOutboxMessagesDeleteContext context) {
- CqnAnalyzer analyzer = CqnAnalyzer.create(context.getModel());
- AnalysisResult analysisResult = analyzer.analyze(context.getCqn());
- Map key = analysisResult.rootKeys();
-
- this.db.run(Delete.from(Messages_.class).byId(key.get(Messages.ID)));
- context.setCompleted();
-}
-```
-
-The injected `PersistenceService` instance is used to perform the operations on the `Messages` entity since the entity `DeadOutboxMessages` is read-only. Both handlers first retrieve the ID of the entry and then they perform the corresponding operation on the database.
-
-[Learn more about CQL statement inspection.](./working-with-cql/query-introspection#cqnanalyzer){.learn-more}
-
-::: tip Use paging logic
-Avoid reading all outbox entries at once in case entries which have large request payloads are present. Prefer `READ`-queries with paging instead.
-:::
-
-## Observability using Open Telemetry
-
-The transactional outbox integrates Open Telemetry for logging telemetry data.
-
-[Learn more about observability with Open Telemetry.](./operating-applications/observability#open-telemetry){.learn-more}
-
-The following KPIs are logged in addition to the spans described in the [observability chapter](./operating-applications/observability):
-
-| KPI Name | Description | KPI Type |
-| ------------------------------------------ | ------------------------------------------------------------------------------------------------------ | -------- |
-| `com.sap.cds.outbox.coldEntries` | Number of entries that could not be delivered after repeated attempts and will not be retried anymore. | Gauge |
-| `com.sap.cds.outbox.remainingEntries` | Number of entries which are pending for delivery. | Gauge |
-| `com.sap.cds.outbox.maxStorageTimeSeconds` | Maximum time in seconds an entry was residing in the outbox. | Gauge |
-| `com.sap.cds.outbox.medStorageTimeSeconds` | Median time in seconds of an entry stored in the outbox." | Gauge |
-| `com.sap.cds.outbox.minStorageTimeSeconds` | Minimal time in seconds an entry was stored in the outbox. | Gauge |
-| `com.sap.cds.outbox.incomingMessages` | Number of incoming messages of the outbox. | Counter |
-| `com.sap.cds.outbox.outgoingMessages` | Number of outgoing messages of the outbox. | Counter |
-
-The KPIs are logged per microservice instance (in case of horizontal scaling), outbox, and tenant.
diff --git a/node.js/_menu.md b/node.js/_menu.md
index bca3500453..d74179cdbf 100644
--- a/node.js/_menu.md
+++ b/node.js/_menu.md
@@ -39,7 +39,6 @@
## [Class cds. Event](events#cds-event)
## [Class cds. Request](events#cds-request)
## [Error Handling](events#req-reject)
- ## [Event Queues](queue)
# [cds. Queries](cds-ql)
@@ -55,6 +54,7 @@
# [cds. env](cds-env)
# [cds. utils](cds-utils)
+# [Event Queues](event-queues)
# [Serving Fiori UIs](fiori)
# [Transactions](cds-tx)
# [Security](authentication)
diff --git a/node.js/assets/dead-letter-queue-1.js b/node.js/assets/dead-letter-queue-1.js
index 75becf250f..f5218040a5 100644
--- a/node.js/assets/dead-letter-queue-1.js
+++ b/node.js/assets/dead-letter-queue-1.js
@@ -3,7 +3,7 @@ const cds = require('@sap/cds')
module.exports = class OutboxDeadLetterQueueService extends cds.ApplicationService {
async init() {
this.before('READ', 'DeadOutboxMessages', function (req) {
- const { maxAttempts } = cds.env.requires.outbox
+ const { maxAttempts } = cds.env.requires.queue
req.query.where('attempts >= ', maxAttempts)
})
diff --git a/node.js/event-queues.md b/node.js/event-queues.md
new file mode 100644
index 0000000000..18fe007514
--- /dev/null
+++ b/node.js/event-queues.md
@@ -0,0 +1,303 @@
+---
+synopsis: >
+ Node.js APIs and configuration for CAP's Transactional Event Queues — `cds.queued`, `cds.unqueued`, `srv.schedule`, `cds.flush`, callbacks, and queue configuration.
+status: released
+---
+
+# Event Queues in Node.js
+
+For concepts, use cases, and guarantees, see the [Transactional Event Queues](../guides/events/event-queues) guide. This page covers the Node.js-specific APIs and configuration on top of that.
+
+In Node.js, you wrap a service with `cds.queued()` to queue its events, or enable queueing through configuration. The persistent queue is the default for all queued services.
+
+[[toc]]
+
+
+## Programmatic API
+
+### Queueing a Service
+
+#### `cds.queued(srv)` { .method }
+
+```tsx
+function cds.queued ( srv: Service ) => QueuedService
+```
+
+Wrap a non-database service in `cds.queued()` to obtain a queued proxy. All `emit` / `send` / `run` calls on the proxy are persisted in the current transaction and dispatched after commit:
+
+```js
+const srv = await cds.connect.to('yourService')
+const qd_srv = cds.queued(srv)
+
+await qd_srv.emit('someEvent', { some: 'message' }) // persisted, dispatched async
+await qd_srv.send('someEvent', { some: 'message' })
+```
+
+::: tip `await` is still needed
+The persistent queue writes the message to the database within the current transaction; you still need to `await` to keep that write inside the transaction.
+:::
+
+For backwards compatibility, `cds.outboxed(srv)` works as a synonym.
+
+#### `cds.unqueued(srv)` { .method }
+
+```tsx
+function cds.unqueued ( srv: QueuedService ) => Service
+```
+
+Get back the original synchronous service from a queued proxy:
+
+```js
+const srv = cds.unqueued(qd_srv)
+```
+
+This is useful when a service is queued through configuration and you need a synchronous call site. For backwards compatibility, `cds.unboxed(srv)` works as a synonym.
+
+#### Queueing through Configuration
+
+You can outbox any *outbound* service through configuration without changing code. The `outboxed` flag on the service config is the trigger:
+
+```json
+{
+ "requires": {
+ "yourService": {
+ "kind": "odata",
+ "outboxed": true
+ }
+ }
+}
+```
+
+Some services — `cds.MessagingService` and `cds.AuditLogService` — are outboxed by default; see [*Auto-Outboxed Services*](../guides/events/event-queues#auto-outboxed-services) in the common guide.
+
+
+### Scheduling
+
+`srv.schedule()` is a shortcut for `cds.queued(srv).send()` with optional timing:
+
+```js
+await srv.schedule('someEvent', { some: 'message' }) // execute asap
+await srv.schedule('someEvent', { some: 'message' }).after('1h') // delay
+await srv.schedule('someEvent', { some: 'message' }).every('10 minutes') // recurrence
+await srv.schedule('someEvent', { some: 'message' }).every('*/10 * * * *') // cron
+```
+
+`.after()` accepts milliseconds (as a number) or a time string such as `'1s'`, `'10m'`, `'1h'`. `.every()` accepts the same plus a five-field cron expression.
+
+#### Singleton Tasks
+
+A *singleton task* is identified by name and exists only once. Subsequent calls with the same name overwrite the previous schedule (tasks are upserted, not deduplicated). This is convenient for idempotent registration during application startup:
+
+> [!note] Node.js only
+> Singleton tasks have no Java equivalent yet. See [*Stack Differences at a Glance*](../guides/events/event-queues#stack-differences-at-a-glance).
+
+```js
+// Replace any existing 'replicate' task with a new schedule
+await srv.schedule.task('replicate', { entity: 'Airports' }).every('10 minutes')
+
+// Remove the task
+await srv.unschedule.task('replicate')
+```
+
+The event name doubles as the task name.
+
+
+### Callback Events
+
+> [!note] Node.js only
+> Callback events have no Java equivalent yet, but they're on the roadmap. See [*Stack Differences at a Glance*](../guides/events/event-queues#stack-differences-at-a-glance).
+
+Once a queued message has been successfully processed, the runtime emits `/#succeeded` on the same service:
+
+```js
+srv.after('someEvent/#succeeded', (data, req) => {
+ // `data` is the result of the event processor
+ console.log('Message successfully processed:', data)
+})
+```
+
+Similarly, when a message becomes a dead letter (after all retries are exhausted), the runtime emits `/#failed`:
+
+```js
+srv.after('someEvent/#failed', (data, req) => {
+ // `data` is the error from the event processor
+ console.log('Message could not be processed:', data)
+})
+```
+
+::: tip Register on specific events
+Callback handlers must be registered for the specific `#succeeded` or `#failed` events. The `*` wildcard handler is not called for these events.
+:::
+
+
+### Manual Processing
+
+> [!note] Node.js only
+> `cds.flush()` is a Node.js API; both stacks have built-in recovery mechanisms that pick up pending messages automatically. See [*Stack Differences at a Glance*](../guides/events/event-queues#stack-differences-at-a-glance).
+
+You rarely need to trigger processing manually — both single-tenant and multi-tenant runners pick up pending messages automatically. The most common use case is recovery after an application crash, where another emit for the same tenant and service would otherwise be needed to restart processing:
+
+```js
+// Flush a specific queue
+const srv = await cds.connect.to('yourService')
+await cds.flush(srv.name)
+
+// Flush all queues
+await cds.flush()
+```
+
+
+## Configuration
+
+### Persistent Queue
+
+The persistent queue is enabled by default. Messages are stored in the `cds.outbox.Messages` table within the current transaction.
+
+```json
+{
+ "requires": {
+ "scheduling": {},
+ "queue": {
+ "kind": "persistent-queue",
+ "maxAttempts": 20,
+ "chunkSize": 10,
+ "parallel": true,
+ "storeLastError": true,
+ "timeout": "1h"
+ }
+ }
+}
+```
+
+::: warning `legacyLocking` and rolling upgrades
+The locking mechanism changed across `@sap/cds` major versions: cds 8 doesn't check the `status` column at all, cds 9 checks it but holds row locks for the duration of processing (`legacyLocking: true` was the cds 9 default), and cds 10 uses application-level locking via `status` and releases the row lock after selection. A rolling upgrade from cds 8 directly to cds 10 can lead to **double-processing of messages** — plan downtime, drain the queue first, or upgrade through cds 9.
+:::
+
+::: details Queue and scheduling options
+
+`cds.requires.queue`:
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `maxAttempts` | `20` | Maximum retries before a message becomes a dead letter |
+| `chunkSize` | `10` | Number of messages to process per batch |
+| `parallel` | `true` | Process messages in parallel |
+| `storeLastError` | `true` | Store error information of the last failed attempt |
+| `timeout` | `"1h"` | Time after which a `processing` message is considered abandoned and eligible for reprocessing |
+| `legacyLocking` | `false` | Backward compatibility with `@sap/cds` v9; to be removed in a future release |
+
+`cds.requires.scheduling` (multitenancy coordination):
+
+| Option | Description |
+|--------|-------------|
+| `markerInterval` | Grid interval for markers; CAP picks a default that spreads tenant load across the interval |
+| `flushInterval` | Cadence at which the central runner checks for tenants with pending work |
+
+:::
+
+
+### In-Memory Queue
+
+For development and testing, the in-memory queue holds messages until the current transaction commits, then emits them — without persistence:
+
+```json
+{
+ "requires": {
+ "queue": {
+ "kind": "in-memory-queue"
+ }
+ }
+}
+```
+
+This is similar to the following code if done manually:
+
+```js
+cds.context.on('succeeded', () => this.emit(msg))
+```
+
+::: warning No retry mechanism
+With the in-memory queue, messages are lost if processing fails. There is no retry, no dead letter queue, and no recovery on application restart.
+:::
+
+
+### Disabling the Queue
+
+Disable event queues globally:
+
+```json
+{ "cds": { "requires": { "queue": false } } }
+```
+
+Or disable queueing for a specific service — for example to make `cds.MessagingService` emit immediately:
+
+```json
+{
+ "requires": {
+ "messaging": {
+ "kind": "enterprise-messaging",
+ "outboxed": false
+ }
+ }
+}
+```
+
+
+## Troubleshooting
+
+### Inspecting `cds.outbox.Messages`
+
+To see what's currently queued, query `cds.outbox.Messages` directly. The columns most useful for triage are `status`, `attempts`, `target`, `lastError`, and `lastAttemptTimestamp`:
+
+```js
+const db = await cds.connect.to('db')
+const messages = await SELECT.from('cds.outbox.Messages')
+ .columns('ID', 'target', 'status', 'attempts', 'lastAttemptTimestamp', 'lastError')
+ .orderBy('timestamp desc')
+```
+
+For a managed view with bound *revive* and *delete* actions, see [*Dead Letter Queue*](#dead-letter-queue) below.
+
+
+### Manually Deleting Entries
+
+To clear stuck messages programmatically:
+
+```js
+const db = await cds.connect.to('db')
+await DELETE.from('cds.outbox.Messages')
+```
+
+
+### Messages Table Not Found
+
+If the `cds.outbox.Messages` table is missing from the database, the most common cause is insufficient model configuration in *package.json*. If you've overwritten `requires.db.model`, add the outbox model path:
+
+```jsonc
+"requires": {
+ "db": { ...
+ "model": [..., "@sap/cds/srv/outbox"]
+ }
+}
+```
+
+For projects on `@sap/cds < 6.7.0` with custom build tasks that override `options.model`, add the path there too:
+
+```jsonc
+"build": {
+ "tasks": [{ ...
+ "options": { "model": [..., "@sap/cds/srv/outbox"] }
+ }]
+}
+```
+
+Note that the model configuration isn't required for CAP projects using the standard project layout with `db`, `srv`, and `app` folders.
+
+
+## Dead Letter Queue
+
+The dead-letter queue lifecycle (define service → filter for dead entries → bound revive/delete actions) is the same shape across both stacks; see [*Dead Letter Queue*](../guides/events/event-queues#dead-letter-queue) in the common guide for the full flow with code in both Node.js and Java.
+
+---
+
+Working in Java? See [Event Queues in Java](../java/event-queues).
diff --git a/node.js/messaging.md b/node.js/messaging.md
index 9335ad2a4c..36503b5e67 100644
--- a/node.js/messaging.md
+++ b/node.js/messaging.md
@@ -265,7 +265,7 @@ this.after(['CREATE', 'UPDATE', 'DELETE'], 'Reviews', async (_, req) => {
```
::: tip
The messages are sent once the transaction is successful.
-Per default, a persistent queue is used. See [Messaging - Queue](./queue) for more information.
+Per default, a persistent queue is used. See [Messaging - Queue](./event-queues) for more information.
:::
## Receiving Events
@@ -300,7 +300,7 @@ In general, messages don't contain user information but operate with a technical
### Inbox
-You can store received messages in an inbox before they're processed. Under the hood, it uses the [task queue](./queue) for reliable asynchronous processing.
+You can store received messages in an inbox before they're processed. Under the hood, it uses the [task queue](./event-queues) for reliable asynchronous processing.
Enable it by setting the `inboxed` option to `true`, for example:
```js
diff --git a/node.js/queue.md b/node.js/queue.md
deleted file mode 100644
index 1d10ba32fe..0000000000
--- a/node.js/queue.md
+++ /dev/null
@@ -1,342 +0,0 @@
----
-synopsis: >
- Learn details about the task queue feature.
-status: released
----
-
-# Queueing with `cds.queued`
-
-[[toc]]
-
-
-
-## Overview
-
-The _task queue_ feature allows you to defer event processing.
-
-A common use case is the outbox pattern, where remote operations are deferred until the main transaction has been successfully committed.
-This prevents accidental execution of remote calls in case the transaction is rolled back.
-
-Every non-database CAP service can be _queued_, meaning that event dispatching becomes _asynchronous_.
-
-::: tip
-The _task queue_ feature can be disabled globally via cds.requires.queue = false.
-:::
-
-
-## Queueing a Service
-
-
-### cds. queued (srv) {.method}
-
-```tsx
-function cds.queued ( srv: Service ) => QueuedService
-```
-
-Programmatically, you can get the queued service as follows:
-
-```js
-const srv = await cds.connect.to('yourService')
-const qd_srv = cds.queued(srv)
-
-await qd_srv.emit('someEvent', { some: 'message' }) // asynchronous
-await qd_srv.send('someEvent', { some: 'message' }) // asynchronous
-```
-
-::: tip `await` needed
-You still need to `await` these operations because they're asynchronous. In case of a persistent queue, which is the default, messages are stored in the database, within the current transaction.
-:::
-
-For backwards compatibility, `cds.outboxed(srv)` works as a synonym.
-
-
-### cds. unqueued (srv) {.method}
-
-```tsx
-function cds.unqueued ( srv: QueuedService ) => Service
-```
-
-Use this on a queued service to get back to the original service:
-
-```js
-const srv = cds.unqueued(qd_srv)
-```
-
-This is useful if your service is outboxed (that is, queued) per configuration.
-
-For backwards compatibility, `cds.unboxed(srv)` works as a synonym.
-
-
-### Per Configuration
-
-Some services are outboxed by default; these include [`cds.MessagingService`](messaging) and `cds.AuditLogService`.
-You can configure the outbox behavior by specifying the `outboxed` option in your service configuration.
-
-```json
-{
- "requires": {
- "yourService": {
- "kind": "odata",
- "outboxed": true
- }
- }
-}
-```
-
-For transactional safety, you're encouraged to use the [persistent queue](#persistent-queue), which is enabled by default.
-
-
-
-## Persistent Queue (Default) {#persistent-queue}
-
-The persistent queue is the default configuration.
-
-Using the persistent queue, the to-be-emitted message is stored in a database table within the current transaction, therefore transactional consistency is guaranteed.
-
-::: details You can use the following configuration options:
-
-```json
-{
- "requires": {
- "queue": {
- "kind": "persistent-queue",
- "maxAttempts": 20,
- "storeLastError": true,
- "legacyLocking": true,
- "timeout": "1h"
- }
- }
-}
-```
-
-The optional parameters are:
-
-- `maxAttempts` (default `20`): The number of unsuccessful emits until the message is considered unprocessable. The message will remain in the database table!
-- `storeLastError` (default `true`): Specifies whether error information of the last failed emit is stored in the tasks table.
-- `legacyLocking` (default `true`): If set to `false`, database locks are only used to set the status of the message to `processing` to prevent long-kept database locks. Although this is the recommended approach, it is incompatible with task runners still on `@sap/cds^8`.
-- `timeout` (default `"1h"`): The time after which a message with `status === "processing"` is considered to be abandoned and eligable to be processed again. Only for `legacyLocking === false`.
-
-:::
-
-Once the transaction succeeds, the messages are read from the database table and dispatched.
-If processing was successful, the respective message is deleted from the database table.
-If processing failed, the system retries the message after exponentially increasing delays.
-After a maximum number of attempts, the message is ignored for processing and remains in the database, which
-therefore also acts as a dead letter queue.
-See [Managing the Dead Letter Queue](#managing-the-dead-letter-queue), to learn about how to handle such messages.
-
-There is only one active message processor per service, tenant, app instance, and message.
-This ensures that no duplicate emits happen, except in the highly unlikely case of an app crash right after successful processing but before the message could be deleted.
-
-::: tip Unrecoverable errors
-Some errors during the emit are identified as unrecoverable, for example in [SAP Event Mesh](../guides/events/event-mesh) if the used topic is forbidden.
-The respective message is then updated and the `attempts` field is set to `maxAttempts` to prevent further processing.
-[Programming errors](./best-practices#error-types) crash the server instance and must be fixed.
-To mark your own errors as unrecoverable, you can set `unrecoverable = true` on the error object.
-:::
-
-
-Your database model is automatically extended by the entity `cds.outbox.Messages`:
-
-```cds
-namespace cds.outbox;
-
-entity Messages {
- key ID : UUID;
- timestamp : Timestamp;
- target : String;
- msg : LargeString;
- attempts : Integer default 0;
- partition : Integer default 0;
- lastError : LargeString;
- lastAttemptTimestamp : Timestamp @cds.on.update: $now;
- status : String(23);
-}
-```
-
-In your CDS model, you can refer to the entity `cds.outbox.Messages` using the path `@sap/cds/srv/outbox`, for example to expose it in a service (cf. [Managing the Dead Letter Queue](#managing-the-dead-letter-queue)).
-
-
-### Known Limitations
-
-- If the app crashes, another emit for the respective tenant and service is necessary to restart the message processing. It can be triggered manually using the `flush` method.
-- The service that handles the queued event must not rely on user roles and attributes, as they are not stored with the message. In other words, asynchronous task are always processed in a privileged mode. However, the user ID is stored to re-create the correct context.
-
-
-### Managing the Dead Letter Queue
-
-You can manage the dead letter queue by implementing a service that exposes a read-only projection on entity `cds.outbox.Messages` as well as bound actions to either revive or delete the respective message.
-
-::: tip
-See [Outbox Dead Letter Queue](../java/outbox#outbox-dead-letter-queue) in the CAP Java documentation for additional considerations while we work on a general outbox guide.
-:::
-
-#### 1. Define the Service
-
-::: code-group
-```cds [srv/outbox-dead-letter-queue-service.cds]
-using from '@sap/cds/srv/outbox';
-
-@requires: 'internal-user'
-service OutboxDeadLetterQueueService {
-
- @readonly
- entity DeadOutboxMessages as projection on cds.outbox.Messages
- actions {
- action revive();
- action delete();
- };
-
-}
-```
-:::
-
-#### 2. Filter for Dead Entries
-
-As `maxAttempts` is configurable, its value cannot be added as a static filter to projection `DeadOutboxMessages`, but must be considered programmatically.
-
-::: code-group
-<<< ./assets/dead-letter-queue-1.js#snippet{5-8} [srv/outbox-dead-letter-queue-service.js]
-:::
-
-#### 3. Implement Bound Actions
-
-Finally, entries in the dead letter queue can either be _revived_ by resetting the number of attempts (that is, `SET attempts = 0`) or _deleted_.
-
-::: code-group
-<<< ./assets/dead-letter-queue-2.js#snippet{10-12,14-16} [srv/outbox-dead-letter-queue-service.js]
-:::
-
-
-### Additional APIs
-
-#### Task Scheduling
-
-You can use the `schedule` method as a shortcut for `cds.queued(srv).send()`, with optional scheduling options `after` and `every`:
-
-```js
-await srv.schedule('someEvent', { some: 'message' })
-await srv.schedule('someEvent', { some: 'message' }).after('1h') // after one hour
-await srv.schedule('someEvent', { some: 'message' }).every('1h') // every hour after each processing
-```
-
-#### Task Processing
-
-To manually trigger the message processing, for example if your server is restarted, you can use the `flush` method.
-
-```js
-const srv = await cds.connect.to('yourService')
-cds.queued(srv).flush()
-```
-
-#### Task Callbacks
-
-Once a message has been successfully processed, it triggers the `/#succeeded` handlers.
-
-```js
-srv.after('someEvent/#succeeded', (data, req) => {
- // `data` is the result of the event processor
- console.log('Message successfully processed:', data)
-})
-```
-
-Similarly, you can use the `/#failed` event to handle failed messages (once the maximum retry count is reached).
-
-```js
-srv.after('someEvent/#failed', (data, req) => {
- // `data` is the error from the event processor
- console.log('Message could not be processed:', data)
-})
-```
-
-::: tip Register on specific events
-Event handlers have to be registered for these specific events. The `*` wildcard handler is not called for these.
-:::
-
-
-
-## In-Memory Queue
-
-You can enable the in-memory queue globally with:
-
-```json
-{
- "requires": {
- "queue": {
- "kind": "in-memory-queue"
- }
- }
-}
-```
-
-Messages are emitted only after the current transaction is successfully committed. Until then, messages are only kept in memory.
-This is similar to the following code if done manually:
-
-```js
-cds.context.on('succeeded', () => this.emit(msg))
-```
-
-::: warning No retry mechanism
-The message is lost if the emit fails. There's no retry mechanism.
-:::
-
-
-
-## Immediate Emit
-
-To disable deferred emitting for a particular service only, you can set the `outboxed` option of that service to `false`:
-
-```json
-{
- "requires": {
- "messaging": {
- "kind": "enterprise-messaging",
- "outboxed": false
- }
- }
-}
-```
-
-
-
-## Troubleshooting
-
-
-### Delete Entries in the Messages Table
-
-To manually delete entries in the table `cds.outbox.Messages`, you can either
-expose it in a service, see [Managing the Dead Letter Queue](#managing-the-dead-letter-queue), or programmatically modify it using the `cds.outbox.Messages`
-entity:
-
-```js
-const db = await cds.connect.to('db')
-await DELETE.from('cds.outbox.Messages')
-```
-
-
-### Messages Table Not Found
-
-If the messages table is not found on the database, this can be caused by insufficient configuration data in _package.json_.
-
-In case you have overwritten `requires.db.model` there, make sure to add the outbox model path `@sap/cds/srv/outbox`:
-
-```jsonc
-"requires": {
- "db": { ...
- "model": [..., "@sap/cds/srv/outbox"]
- }
-}
-```
-
-The following is only relevant if you're using @sap/cds version < 6.7.0 and you've configured `options.model` in custom build tasks.
-Add the model path accordingly:
-
-```jsonc
-"build": {
- "tasks": [{ ...
- "options": { "model": [..., "@sap/cds/srv/outbox"] }
- }]
-}
-```
-
-Note that model configuration isn't required for CAP projects using the standard project layout with `db`, `srv`, and `app` folders. In this case, you can delete the entire `model` configuration.
diff --git a/redirects.md b/redirects.md
index cc6ad860eb..2c7c01f270 100644
--- a/redirects.md
+++ b/redirects.md
@@ -88,6 +88,7 @@
- [java/indicating-errors](java/event-handlers/indicating-errors)
- [java/messaging-foundation](java/messaging)
- [java/observability](java/operating-applications/observability)
+- [java/outbox](java/event-queues)
- [java/overview](java/getting-started)
- [java/persistence-services](java/cqn-services/persistence-services)
- [java/provisioning-api](java/event-handlers)
@@ -104,7 +105,8 @@
- [node.js/cds-dk](tools/apis/cds-import)
- [node.js/middlewares](node.js/cds-serve)
-- [node.js/outbox](node.js/queue)
+- [node.js/outbox](node.js/event-queues)
+- [node.js/queue](node.js/event-queues)
- [node.js/protocols](node.js/cds-serve)
- [node.js/requests](node.js/events)
- [node.js/services](node.js/core-services)