apache / apache/pekko-persistence-r2dbc
# Opt-in batched write journal to coalesce concurrent writes across persistence ids
- Dominant language
- Scala
- Stars
- 21
- Forks
- 14
- Avg merge
- 13h 51m
- Merged PRs (30d)
- 15
Description
## What and why
Pekko Persistence serialises a single persistent actor's writes, so the default `R2dbcJournal` issues one prepared statement and one commit per `AtomicWrite`. With many entities writing concurrently, throughput is dominated by per-row round-trips and commit/WAL fsync, and every concurrent writer holds a connection from the pool.
Proposal: an opt-in journal plugin that coalesces concurrent writes from different persistence ids into one multi-row statement, trading a small, bounded amount of write latency for higher throughput at high concurrency. The default journal is unchanged; existing users aren't affected.
## Proposed approach
A new journal class behind a new plugin id (`pekko.persistence.r2dbc.batched-journal`), enabled by pointing `pekko.persistence.journal.plugin` at it. Design outline:
- Writes go onto a bounded queue, coalesced with `groupedWithin(batch.max-requests, batch.window)`, and each group is written as one multi-row INSERT in one transaction.
- A small number of groups (`batch.parallelism`) is written concurrently, bounded by the connection pool.
- If a group write fails (say, a duplicate sequence number), the group is retried in halves so only the genuinely failing write fails and the rest succeed.
- The buffer is bounded; on overflow new writes are rejected (the originating persistent actor sees the write fail) rather than blocking.
- Requirements checked at startup: `use-app-timestamp = on`, `db-timestamp-monotonic-increasing = on`, and the postgres or yugabyte dialect. A coalesced batch mixes persistence ids, so the default journal's per-persistence-id timestamp subselect can't be used.
## Directional measurements (loopback PostgreSQL, JDK 17)
Preliminary micro-benchmarks on loopback, the pessimistic case for batching since near-zero network RTT favors the single-insert baseline:
- Below roughly `max-requests` concurrent writers, batches flush on the window timer and batching is slower than the default. At or above it, batching breaks even around 60-200 concurrent writers and reaches roughly 1.4-2.2x over the default at 1k-3k writers.
- Against a networked database the crossover should move to lower concurrency and the advantage grow, because batching collapses per-event round-trips and fsyncs.
- Event size matters: for large events (roughly above 64 KB) the single insert wins even at high concurrency. The proposal therefore keeps batching focused on many small concurrent writes.
These numbers come from one environment. They justify the approach; they're not a performance claim.
## Open questions for discussion
1. Is cross-entity write batching in scope for this plugin at all? Its stated goal is efficiency for Postgres-compatible distributed databases, and the single-row write path has deliberately stayed simple so far.
2. The required settings (`use-app-timestamp`, `db-timestamp-monotonic-increasing`) and the postgres/yugabyte-only restriction: acceptable, or should the design avoid the app-timestamp requirement?
3. Latency: every write pays up to `batch.window` (default 2 ms) extra. Is that an acceptable opt-in trade-off, and is 2 ms a sane default?
4. Overload behavior: reject writes on queue overflow (fail fast) versus blocking. Which contract do we want?
5. Naming and config shape: `batched-journal` plugin id, `batch.*` settings, defaults.
6. Should a prototype UNNEST-based multi-row insert (single statement with column-oriented array parameters, faster than an add() batch for small events but needs a payload-size cap to bound off-heap memory) be part of the same change, or kept as a separate follow-up?
## Alternatives considered
- Raising the connection pool and relying on the default journal: does not reduce per-row round-trips or commits, and increases pool pressure.
- Client-side batching in the application: duplicated logic, no atomicity across entities, harder to get right.
- Only publishing a multi-row add() batch with no UNNEST variant: simpler, but leaves measurable throughput on the table for small-event high-concurrency workloads.
Contributor guide
Research direction
Start by reading the existing R2dbcJournal write path and the journal plugin configuration, then compare them with the proposed batched-journal requirements. Resolve the open questions around batching, retries, overflow, startup validation, and PostgreSQL-compatible dialects before implementation. Done means an opt-in journal coalesces bounded concurrent writes without changing the default journal.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- postgresql, scala
- Domain
- backend, databases
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100