GitHub user Aetherance created a discussion: Proposal: Keyspace Notifications 
for Kvrocks

## 1. Background and goals

Applications reading from a read-only Kvrocks replica keep a local cache to cut 
latency and reduce round-trips to the primary. Keeping that cache fresh 
requires a notification when a key changes. Kvrocks has no such mechanism 
today, which blocks applications that depend on Redis Keyspace Notifications 
from migrating.

This design implements the minimal Redis-compatible Keyspace Notifications 
surface needed by #2915:

- `notify-keyspace-events` configuration.
- Events published to `__keyspace@<db>__:<key>` with payload = event name.
- Events published to `__keyevent@<db>__:<event>` with payload = key.
- Consumption through the existing `SUBSCRIBE` / `PSUBSCRIBE`.
- `SET` and `DEL` notifications on both primary and replica.

The MVP is intentionally narrow. It does not infer full Redis command semantics 
from RocksDB storage mutations: those records cannot distinguish `SET` from 
`APPEND`, `SETRANGE`, or `INCR`, nor a genuine `DEL` from lazy expiry or 
internal delete-and-recreate flows.

## 2. Architecture

Redis emits keyspace events at the command execution point, and replicas 
re-emit them by replaying the replicated command stream. Kvrocks replicates the 
RocksDB WAL (`WriteBatch`), not Redis commands, so replicas cannot recover 
exact command semantics from storage mutations alone.

The design therefore stores explicit keyspace event markers in the same 
`WriteBatch` as the data mutation:

```mermaid
flowchart TD
    subgraph primary["Primary / standalone"]
        cmd["Command layer<br/>SET / DEL succeeds"]
        marker["Append event marker<br/>to the same WriteBatch"]
        write["db_->Write(batch) succeeds"]
        decode_primary["Decode markers<br/>from the batch"]
        notify_primary["NotifyKeyspaceEvent"]
    end

    subgraph replica["Replica"]
        recv["Receive replicated WriteBatch"]
        apply["Apply batch succeeds"]
        decode_replica["Decode markers<br/>from the same batch"]
        notify_replica["NotifyKeyspaceEvent"]
    end

    pub["PublishMessage<br/>keyspace / keyevent"]
    clients["Local SUBSCRIBE / PSUBSCRIBE subscribers"]

    cmd --> marker --> write --> decode_primary --> notify_primary
    marker -. "replicated with WriteBatch" .-> recv
    recv --> apply --> decode_replica --> notify_replica
    notify_primary --> pub
    notify_replica --> pub
    pub --> clients
```

This keeps the WAL as the replication carrier without guessing event names from 
column-family writes. The primary publishes after `db_->Write` succeeds; the 
replica publishes after it applies the same batch. With the same local 
`notify-keyspace-events` setting, both roles publish the same marker-derived 
events.

Pub/Sub fan-out is reused unchanged: `Server::PublishMessage` already handles 
exact-channel and pattern subscriptions and is a pure in-memory operation, so 
it works on read-only replicas.

## 3. Design decisions

### 3.1 Config: `notify-keyspace-events`

Add the `notify-keyspace-events` config. The default is `""` (disabled). The 
parser accepts the Redis 7 flag character set `K E A g $ l s h z x e t m n d`, 
rejects unknown characters, and supports runtime updates through `CONFIG SET`.

The MVP accepts the full flag set for compatibility, but only `g` (`del`) and 
`$` (`set`) currently produce notifications. Internally these flags are parsed 
into non-overlapping bits:

| Constant | Bit | Flag | Meaning |
|---|---:|---|---|
| `kKeyspace` | `1 << 0` | `K` | publish keyspace channels |
| `kKeyEvent` | `1 << 1` | `E` | publish keyevent channels |
| `kGeneric` | `1 << 2` | `g` | generic events; MVP emits `del` |
| `kString` | `1 << 3` | `$` | string events; MVP emits `set` |
| `kList` | `1 << 4` | `l` | accepted, no MVP emitter |
| `kSet` | `1 << 5` | `s` | accepted, no MVP emitter |
| `kHash` | `1 << 6` | `h` | accepted, no MVP emitter |
| `kZSet` | `1 << 7` | `z` | accepted, no MVP emitter |
| `kExpired` | `1 << 8` | `x` | accepted, no MVP emitter |
| `kEvicted` | `1 << 9` | `e` | accepted, no MVP emitter |
| `kStream` | `1 << 10` | `t` | accepted, no MVP emitter |
| `kKeyMiss` | `1 << 11` | `m` | accepted, no MVP emitter |
| `kNew` | `1 << 12` | `n` | accepted, no MVP emitter |
| `kModule` | `1 << 13` | `d` | accepted, no MVP emitter |

`A` expands to `g$lshzxetd` for Redis compatibility. It does not include `m` 
(`keymiss`) or `n` (`new`), which must be enabled explicitly. In this MVP, only 
`$` and `g` produce events, so the common `KEA` setting enables both channel 
forms and lets `set` / `del` pass the filter.

The other classes enabled by `A` are accepted but inert for now. If later 
releases add markers for list/hash/etc., existing `KEA` deployments will 
naturally start receiving those events; that matches Redis semantics, but 
should be called out in release notes.

### 3.2 Event marker format

Add a keyspace-event log-data record that can be appended to a RocksDB 
`WriteBatch`:

```cpp
struct KeyspaceEventLogData {
  int type_class;      // KeyspaceEvent::kString or KeyspaceEvent::kGeneric
  std::string event;   // "set" or "del"
  std::string ns;      // Kvrocks namespace
  std::string key;     // user key
};
```

The record is encoded with `WriteBatch::PutLogData()` using a versioned, 
non-numeric binary tag:

```text
"kse1"                      // 4-byte ASCII tag, leading 'k'
uint8  type_class_code       // 1 = kString, 2 = kGeneric
uint8  event_code            // 1 = set, 2 = del
uint32 namespace_len_le
bytes  namespace
uint32 key_len_le
bytes  key
```

`type_class_code` and `event_code` use compact one-byte encodings. The decoder 
maps them back to the event class and event name before calling the emitter.

Markers use an independent `kse1` tag instead of reusing normal 
`redis::WriteBatchLogData`. This avoids confusion with existing Redis type logs 
and lets older readers treat unknown markers as skippable log data rather than 
command-reconstruction context.

Markers are self-contained: each record carries its own event class, event 
name, namespace, and key. The decoder does not infer the affected key from 
neighboring `PutCF` / `DeleteCF` records or depend on marker position inside 
the batch. Writers may place a marker after the mutation it describes for 
debuggability, but correctness does not rely on that order.

Unknown versions, unknown codes, truncated payloads, and malformed markers are 
skipped after at most a warning; they must not fail an already committed write 
or block replication. During rolling upgrades, older nodes may ignore `kse1` 
markers, but they must still apply and continue replicating batches that 
contain them.

Markers are semantic facts, not publication decisions. They are written when a 
command has definitely performed a supported mutation. Each node still applies 
its own `notify-keyspace-events` filter when publishing.

Marker write policy:

- If the primary's `notify-keyspace-events` enables no supported class bit (`g` 
/ `$`), supported commands may skip writing markers to avoid default-off 
overhead. The skip keys only on class bits, never on the `K` / `E` channel 
selectors, so a primary with a class bit but no selector still writes markers 
for replicas to publish.
- If the primary skips markers, replicas cannot publish notifications for that 
write even if their local config is later enabled (the operational consequence 
for replica caches is stated as a guarantee in §5).
- When enabled, each supported `SET` / deleted key adds one small log-data 
record to the WAL and replication stream. This is the cost of preserving 
command semantics for replicas.

Configuration is thus checked twice — at marker-write time on the primary and 
at publish time on each node; a `CONFIG SET` that disables notifications 
between the two still ships the marker to replicas but suppresses the primary's 
local publish.

### 3.3 Marker writers

Only commands with unambiguous MVP semantics write markers:

- `SET`: append a `set` marker only when the command actually writes the key. 
Conditional variants such as `SET key value NX` must not write a marker when 
the condition fails.
- `DEL`: append one `del` marker per key actually deleted, each carrying that 
single key. Missing keys do not produce markers.

Every other write path (the out-of-scope list in §4) writes no markers in this 
MVP. This is deliberate: emitting no event is safer than emitting a Redis event 
name that does not match the command semantics.

### 3.4 Event decoder and trigger points

Primary and replica use the same marker decoder, but invoke it at different 
points:

- The primary decodes the just-written batch after `db_->Write` succeeds, then 
calls `Server::NotifyKeyspaceEvent`.
- The replica applies the replicated batch first, decodes that same batch, then 
calls `Server::NotifyKeyspaceEvent`.

The command layer writes markers but never publishes directly. This keeps 
failed writes silent and prevents a single-node write from publishing once from 
the command path and once from the batch path.

Publication is bound only to live write/apply paths. During restart, RocksDB 
replays WAL internally inside `DB::Open`; that recovery does not invoke the 
notification path, so historical markers are not republished.

Decode errors are non-fatal. A malformed marker is logged and skipped; keyspace 
notifications must never abort a committed write or stall replication.

### 3.5 Event emitter

`Server::NotifyKeyspaceEvent` applies the local config filter and reuses the 
existing Pub/Sub path to publish the two Redis-compatible channel forms:

For a `SET foo bar` marker in the default namespace, `KEA` publishes:

```text
__keyspace@0__:foo  -> "set"
__keyevent@0__:set  -> "foo"
```

For a successful `DEL foo`, it publishes:

```text
__keyspace@0__:foo  -> "del"
__keyevent@0__:del  -> "foo"
```

### 3.6 `<db>` / namespace mapping

For Redis compatibility, the default namespace maps to db `0`, so standard 
subscriptions such as `PSUBSCRIBE __keyevent@0__:*` work out of the box.

Non-default Kvrocks namespaces cannot be inserted into `<db>` directly. A 
namespace literally named `"0"` would collide with Redis db `0` channels and 
could leak events across tenants. The mapping must therefore be collision-free.

`MapNamespaceToKeyspaceDB(ns)` is defined as:

- default namespace -> `"0"`;
- non-default namespace -> `"ns:" + PercentEncode(ns)`, where percent encoding 
escapes every byte outside `[A-Za-z0-9_.-]` and also escapes `%`.

Subscribers to `__keyevent@0__:*` therefore only see the default namespace. 
Subscribers that opt into non-default namespaces use the encoded form, for 
example `__keyevent@ns:tenantA__:*`. The payload remains only the key name, so 
namespace identity comes from the channel.

The key component in `__keyspace@<db>__:<key>` and the payload in 
`__keyevent@<db>__:<event>` are the raw user key bytes, matching Redis 
behavior. Keys are not percent-encoded, even if they contain `:`, whitespace, 
or binary bytes.

## 4. Event scope

Supported MVP events:

| Command | Event class | Event | Emitted when |
|---|---|---|---|
| `SET` | `$` | `set` | the command writes the key |
| `DEL` | `g` | `del` | the key is actually deleted |

Out of scope:

- `APPEND`, `SETRANGE`, `GETSET`, `INCR`, and other string commands.
- Hash/list/set/zset/stream element updates.
- `EXPIRE`, `PEXPIRE`, `expired`, and lazy/compaction expiry.
- `FLUSHDB` / `FLUSHALL` and `DeleteRangeCF` notifications.
- `rename_from`, `rename_to`, `new`, `keymiss`, `evicted`, and module events.

`FLUSHDB` is explicitly a limitation for cache invalidation: this MVP does not 
emit per-key or aggregate flush notifications. Applications that rely on flush 
visibility need a later design for aggregate `flushdb` / `flushall` events.

## 5. Semantics and guarantees

- Best effort only. Notifications are Pub/Sub messages: they are not persisted, 
acknowledged, replayed, or retried.
- Failed writes publish nothing. A marker in an uncommitted batch has no 
visible effect because publication only runs after successful `db_->Write` or 
replica apply.
- A write commits before its local notification publishes; a crash in that 
window can make local subscribers miss the event. Replica notifications publish 
only after apply, so they can lag the primary, and a replica crash between 
apply and publish loses the event — restart recovery does not replay it, which 
also prevents duplicate notifications after crashes.
- `notify-keyspace-events` is node-local, and the primary's setting gates 
marker writing (§3.2). A primary with no supported class bit writes no markers, 
so its replicas publish nothing no matter how they are configured. For the 
#2915 replica-cache use case operators must therefore enable a supported class 
bit (for example `KEA`) on the primary — even if it has no local subscribers — 
and set the same value on every subscribed replica.
- Rolling upgrades rely on unknown-marker tolerance. Older nodes may ignore the 
marker and not publish, but they must still apply and replicate the write batch.
- There is no cross-node de-duplication: a client subscribed to both primary 
and replica may process the same logical mutation twice.
- For one marker, keyspace is published before keyevent on one node. No 
ordering is promised across different markers — multi-key commands like `DEL k1 
k2 k3`, concurrent writes, or events on different nodes. Marker order inside a 
batch is not part of the contract; each marker is self-contained.
- The event stream is a cache-invalidation hint, not a reliable CDC mechanism.

## 6. Tests

Add focused coverage, grouped by concern.

Configuration:

- Flag parsing, invalid-flag rejection, and `CONFIG SET` runtime changes.
- Bit assignments: `K`, `E`, `g`, `$` map to distinct bits matching marker 
`type_class` values.
- `A` expansion sets the Redis all-events class bits (`g$lshzxetd`), excludes 
`m` / `n`, and makes `KEA` pass `set` / `del` through filtering.

Emission:

- `SET` publishes `set` only on success; `NX` / `XX` condition failures publish 
nothing.
- `DEL` publishes one `del` per actually deleted key, none for missing keys.
- `APPEND`, `SETRANGE`, `INCR`, and expiry paths publish no markers.
- Disabled configuration emits nothing; replica subscribers receive `set` / 
`del` from master writes.

Markers and replication:

- Write policy: with primary class bits off, supported commands add no marker 
overhead and replicas have nothing to publish; with them on, markers are 
present.
- Codec: valid `set` / `del` markers round-trip; unknown versions, unknown 
codes, and truncated payloads are skipped without aborting 
`WriteBatch::Iterate`.
- WAL compatibility: a handler that does not recognize `kse1` still iterates 
and applies a batch containing the marker.
- Self-containment: a marker not adjacent to its mutation still decodes from 
its own payload.
- A malformed marker on a replica is logged/skipped without blocking 
replication progress or ack.

Semantics:

- Duplicate prevention: one successful primary write yields exactly one 
publication, not command-layer plus WAL-replay.
- Crash recovery: WAL recovery republishes no markers; apply-time publication 
is best-effort and not replayed after restart.
- Runtime race: flags changed by `CONFIG SET` between marker write and 
publication take effect at publish time.
- Ordering: keyspace precedes keyevent for one marker; no test relies on 
cross-marker ordering.
- Namespace mapping: default maps to `0`; non-default names are prefixed and 
percent-encoded, including numeric names like `"0"`, so tenant channels cannot 
collide.

## 7. References

- [#2915](https://github.com/apache/kvrocks/issues/2915) — feature request 
driving this design.
- [Redis Keyspace 
Notifications](https://redis.io/docs/latest/develop/use/keyspace-notifications/)
 — the compatibility target for channels, payloads, and flags.

GitHub link: https://github.com/apache/kvrocks/discussions/3533

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: [email protected]

Reply via email to