From b92690d7fda992baa2d9399655e4dbc04da578fb Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Thu, 21 May 2026 14:32:46 +0200 Subject: [PATCH 01/24] add first draft version to support PostNotificationJobsPerKey --- src/module.c | 162 +++++++++++++++++- src/redismodule.h | 3 + tests/modules/postnotifications.c | 25 +++ tests/unit/moduleapi/ksn_notify_coverage.md | 173 ++++++++++++++++++++ tests/unit/moduleapi/postnotifications.tcl | 48 ++++++ 5 files changed, 402 insertions(+), 9 deletions(-) create mode 100644 tests/unit/moduleapi/ksn_notify_coverage.md diff --git a/src/module.c b/src/module.c index 9843e6ccc..0d2c7f3f8 100644 --- a/src/module.c +++ b/src/module.c @@ -308,6 +308,7 @@ typedef void (*RedisModuleNotificationWithSubkeysFunc)(RedisModuleCtx *ctx, int /* Function pointer type for post jobs */ typedef void (*RedisModulePostNotificationJobFunc) (RedisModuleCtx *ctx, void *pd); +typedef void (*RedisModulePostNotificationJobPerKeyFunc) (RedisModuleCtx *ctx, RedisModuleString *key, void *pd); /* Keyspace notification subscriber information. * See RM_SubscribeToKeyspaceEvents() for more information. */ @@ -327,15 +328,44 @@ typedef struct RedisModuleKeyspaceSubscriber { int active; } RedisModuleKeyspaceSubscriber; -typedef struct RedisModulePostExecUnitJob { - /* The module subscribed to the event */ - RedisModule *module; - RedisModulePostNotificationJobFunc callback; +typedef enum { + POST_EXEC_UNIT_JOB_SINGLE = 0, + POST_EXEC_UNIT_JOB_KEYED = 1, +} PostExecUnitJobType; + +/* Per-key entry inside a keyed PostExecUnit job. The keyed job fans out and + * invokes its callback once per entry, in submission order. */ +typedef struct RedisModulePostExecUnitKeyedEntry { + RedisModuleString *key; /* Owned reference; freed after the callback runs. */ void *pd; void (*free_pd)(void*); +} RedisModulePostExecUnitKeyedEntry; + +typedef struct RedisModulePostExecUnitJob { + PostExecUnitJobType type; + /* The module subscribed to the event */ + RedisModule *module; int dbid; + union { + struct { + RedisModulePostNotificationJobFunc callback; + void *pd; + void (*free_pd)(void*); + } single; + struct { + RedisModulePostNotificationJobPerKeyFunc callback; + list *entries; /* list of RedisModulePostExecUnitKeyedEntry* */ + } keyed; + } u; } RedisModulePostExecUnitJob; +/* Index key used to coalesce keyed post-exec-unit jobs by (module, callback) + * while the current execution unit is still in progress. */ +typedef struct { + RedisModule *module; + RedisModulePostNotificationJobPerKeyFunc callback; +} PostExecUnitKeyedJobIndexKey; + /* The module keyspace notification subscribers list */ static list *moduleKeyspaceSubscribers; @@ -347,6 +377,12 @@ static int moduleKeyspaceSubscribersWithSubkeysTypes = 0; /* The module post keyspace jobs list */ static list *modulePostExecUnitJobs; +/* Index from (module, per-key callback) -> listNode* in modulePostExecUnitJobs. + * Used so that submitting a second key for an already-pending keyed job appends + * to that job's entry list rather than enqueuing a new job. Reset to empty at + * the end of every drain. */ +static dict *modulePostExecUnitKeyedJobIndex; + /* Data structures related to the exported dictionary data structure. */ typedef struct RedisModuleDict { rax *rax; /* The radix tree. */ @@ -9434,12 +9470,40 @@ void firePostExecutionUnitJobs(void) { moduleCreateContext(&ctx, job->module, REDISMODULE_CTX_TEMP_CLIENT); selectDb(ctx.client, job->dbid); - job->callback(&ctx, job->pd); - if (job->free_pd) job->free_pd(job->pd); + if (job->type == POST_EXEC_UNIT_JOB_SINGLE) { + job->u.single.callback(&ctx, job->u.single.pd); + if (job->u.single.free_pd) job->u.single.free_pd(job->u.single.pd); + } else { + /* Keyed job: fan out the callback over each collected key, sharing + * the same context. The index entry is removed so that any keys + * submitted *after* this point (e.g. from a nested KSN triggered + * inside the callback) start a fresh keyed job. */ + PostExecUnitKeyedJobIndexKey idx = { + .module = job->module, + .callback = job->u.keyed.callback, + }; + dictDelete(modulePostExecUnitKeyedJobIndex, &idx); + + listIter li; + listNode *eln; + listRewind(job->u.keyed.entries, &li); + while ((eln = listNext(&li)) != NULL) { + RedisModulePostExecUnitKeyedEntry *e = listNodeValue(eln); + job->u.keyed.callback(&ctx, e->key, e->pd); + if (e->free_pd) e->free_pd(e->pd); + decrRefCount(e->key); + zfree(e); + } + listRelease(job->u.keyed.entries); + } moduleFreeContext(&ctx); zfree(job); } + /* Defensive: any stale index entries (shouldn't be possible since every + * keyed job we enqueued got drained above, but cheap insurance). */ + if (dictSize(modulePostExecUnitKeyedJobIndex) > 0) + dictEmpty(modulePostExecUnitKeyedJobIndex, NULL); exitExecutionUnit(); } @@ -9465,16 +9529,69 @@ int RM_AddPostNotificationJob(RedisModuleCtx *ctx, RedisModulePostNotificationJo return REDISMODULE_ERR; } RedisModulePostExecUnitJob *job = zmalloc(sizeof(*job)); + job->type = POST_EXEC_UNIT_JOB_SINGLE; job->module = ctx->module; - job->callback = callback; - job->pd = privdata; - job->free_pd = free_privdata; job->dbid = ctx->client->db->id; + job->u.single.callback = callback; + job->u.single.pd = privdata; + job->u.single.free_pd = free_privdata; listAddNodeTail(modulePostExecUnitJobs, job); return REDISMODULE_OK; } +/* Sibling of `RM_AddPostNotificationJob` that fans out per-key. Multiple + * submissions of the same (module, callback) pair within a single execution + * unit are coalesced into a single job whose callback is invoked once per + * collected key, in submission order. This lets a module react to several + * keys touched by the same MULTI/EXEC in one atomic propagation block. + * + * Refusal rules, dbid pinning, replication atomicity, and re-entrancy + * semantics are identical to `RM_AddPostNotificationJob`. + * + * `key` must be a valid RedisModuleString. The implementation takes its own + * reference; the caller retains ownership of its own reference and may free + * it at any time. `free_pd` may be NULL. + * + * Return REDISMODULE_OK on success and REDISMODULE_ERR if called while loading + * data from disk (AOF or RDB) or on a read-only replica. */ +int RM_AddPostNotificationJobForKey(RedisModuleCtx *ctx, RedisModulePostNotificationJobPerKeyFunc callback, RedisModuleString *key, void *privdata, void (*free_privdata)(void*)) { + if (server.loading || (server.masterhost && server.repl_slave_ro)) { + return REDISMODULE_ERR; + } + + RedisModulePostExecUnitKeyedEntry *entry = zmalloc(sizeof(*entry)); + entry->key = key; + incrRefCount(entry->key); + entry->pd = privdata; + entry->free_pd = free_privdata; + + PostExecUnitKeyedJobIndexKey idx_key = { + .module = ctx->module, + .callback = callback, + }; + dictEntry *de = dictFind(modulePostExecUnitKeyedJobIndex, &idx_key); + if (de) { + RedisModulePostExecUnitJob *job = dictGetVal(de); + listAddNodeTail(job->u.keyed.entries, entry); + return REDISMODULE_OK; + } + + RedisModulePostExecUnitJob *job = zmalloc(sizeof(*job)); + job->type = POST_EXEC_UNIT_JOB_KEYED; + job->module = ctx->module; + job->dbid = ctx->client->db->id; + job->u.keyed.callback = callback; + job->u.keyed.entries = listCreate(); + listAddNodeTail(job->u.keyed.entries, entry); + listAddNodeTail(modulePostExecUnitJobs, job); + + PostExecUnitKeyedJobIndexKey *idx = zmalloc(sizeof(*idx)); + *idx = idx_key; + dictAdd(modulePostExecUnitKeyedJobIndex, idx, job); + return REDISMODULE_OK; +} + /* Get the configured bitmap of notify-keyspace-events (Could be used * for additional filtering in RedisModuleNotificationFunc) */ int RM_GetNotifyKeyspaceEvents(void) { @@ -12994,6 +13111,31 @@ dictType moduleAPIDictType = { NULL /* allow to expand */ }; +static uint64_t postExecUnitKeyedJobIndexHash(const void *key) { + return dictGenHashFunction(key, sizeof(PostExecUnitKeyedJobIndexKey)); +} + +static int postExecUnitKeyedJobIndexCompare(dictCmpCache *cache, const void *k1, const void *k2) { + UNUSED(cache); + const PostExecUnitKeyedJobIndexKey *a = k1, *b = k2; + return a->module == b->module && a->callback == b->callback; +} + +static void postExecUnitKeyedJobIndexKeyDtor(dict *d, void *k) { + UNUSED(d); + zfree(k); +} + +dictType postExecUnitKeyedJobIndexDictType = { + postExecUnitKeyedJobIndexHash, /* hash function */ + NULL, /* key dup */ + NULL, /* val dup */ + postExecUnitKeyedJobIndexCompare, /* key compare */ + postExecUnitKeyedJobIndexKeyDtor, /* key destructor */ + NULL, /* val destructor */ + NULL /* allow to expand */ +}; + int moduleRegisterApi(const char *funcname, void *funcptr) { return dictAdd(server.moduleapi, (char*)funcname, funcptr); } @@ -13034,6 +13176,7 @@ void moduleInitModulesSystem(void) { moduleKeyspaceSubscribers = listCreate(); modulePostExecUnitJobs = listCreate(); + modulePostExecUnitKeyedJobIndex = dictCreate(&postExecUnitKeyedJobIndexDictType); /* Set up filter list */ moduleCommandFilters = listCreate(); @@ -15573,6 +15716,7 @@ void moduleRegisterCoreAPI(void) { REGISTER_API(SubscribeToKeyspaceEventsWithSubkeys); REGISTER_API(UnsubscribeFromKeyspaceEventsWithSubkeys); REGISTER_API(AddPostNotificationJob); + REGISTER_API(AddPostNotificationJobForKey); REGISTER_API(RegisterClusterMessageReceiver); REGISTER_API(SendClusterMessage); REGISTER_API(GetClusterNodeInfo); diff --git a/src/redismodule.h b/src/redismodule.h index f0d9e8aa6..55557c1e5 100644 --- a/src/redismodule.h +++ b/src/redismodule.h @@ -996,6 +996,7 @@ typedef void (*RedisModuleDisconnectFunc)(RedisModuleCtx *ctx, RedisModuleBlocke typedef int (*RedisModuleNotificationFunc)(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key); typedef void (*RedisModuleNotificationWithSubkeysFunc)(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key, RedisModuleString **subkeys, int count); typedef void (*RedisModulePostNotificationJobFunc) (RedisModuleCtx *ctx, void *pd); +typedef void (*RedisModulePostNotificationJobPerKeyFunc) (RedisModuleCtx *ctx, RedisModuleString *key, void *pd); typedef void *(*RedisModuleTypeLoadFunc)(RedisModuleIO *rdb, int encver); typedef void (*RedisModuleTypeSaveFunc)(RedisModuleIO *rdb, void *value); typedef int (*RedisModuleTypeAuxLoadFunc)(RedisModuleIO *rdb, int encver, int when); @@ -1383,6 +1384,7 @@ REDISMODULE_API int (*RedisModule_UnsubscribeFromKeyspaceEvents)(RedisModuleCtx REDISMODULE_API int (*RedisModule_SubscribeToKeyspaceEventsWithSubkeys)(RedisModuleCtx *ctx, int types, int flags, RedisModuleNotificationWithSubkeysFunc cb) REDISMODULE_ATTR; REDISMODULE_API int (*RedisModule_UnsubscribeFromKeyspaceEventsWithSubkeys)(RedisModuleCtx *ctx, int types, int flags, RedisModuleNotificationWithSubkeysFunc cb) REDISMODULE_ATTR; REDISMODULE_API int (*RedisModule_AddPostNotificationJob)(RedisModuleCtx *ctx, RedisModulePostNotificationJobFunc callback, void *pd, void (*free_pd)(void*)) REDISMODULE_ATTR; +REDISMODULE_API int (*RedisModule_AddPostNotificationJobForKey)(RedisModuleCtx *ctx, RedisModulePostNotificationJobPerKeyFunc callback, RedisModuleString *key, void *pd, void (*free_pd)(void*)) REDISMODULE_ATTR; REDISMODULE_API int (*RedisModule_NotifyKeyspaceEvent)(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) REDISMODULE_ATTR; REDISMODULE_API int (*RedisModule_NotifyKeyspaceEventWithSubkeys)(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key, RedisModuleString **subkeys, int count) REDISMODULE_ATTR; REDISMODULE_API int (*RedisModule_GetNotifyKeyspaceEvents)(void) REDISMODULE_ATTR; @@ -1788,6 +1790,7 @@ static int RedisModule_Init(RedisModuleCtx *ctx, const char *name, int ver, int REDISMODULE_GET_API(SubscribeToKeyspaceEventsWithSubkeys); REDISMODULE_GET_API(UnsubscribeFromKeyspaceEventsWithSubkeys); REDISMODULE_GET_API(AddPostNotificationJob); + REDISMODULE_GET_API(AddPostNotificationJobForKey); REDISMODULE_GET_API(NotifyKeyspaceEvent); REDISMODULE_GET_API(NotifyKeyspaceEventWithSubkeys); REDISMODULE_GET_API(GetNotifyKeyspaceEvents); diff --git a/tests/modules/postnotifications.c b/tests/modules/postnotifications.c index 96fb85918..9ae316363 100644 --- a/tests/modules/postnotifications.c +++ b/tests/modules/postnotifications.c @@ -49,6 +49,27 @@ static void KeySpace_PostNotificationString(RedisModuleCtx *ctx, void *pd) { RedisModule_FreeCallReply(rep); } +/* Per-key post-notification callback: appends each batched key to a single + * list, so the test can assert all keys touched in one execution unit fan + * out into the same MULTI/EXEC replication block. */ +static void KeySpace_PostNotificationBatchedKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "batched_keys", key); + RedisModule_FreeCallReply(rep); +} + +static int KeySpace_NotificationBatched(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "batched_", 8) != 0) return REDISMODULE_OK; + if (strcmp(key_str, "batched_keys") == 0) return REDISMODULE_OK; /* skip our sink list */ + + RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationBatchedKey, key, NULL, NULL); + return REDISMODULE_OK; +} + static int KeySpace_NotificationExpired(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ REDISMODULE_NOT_USED(type); REDISMODULE_NOT_USED(event); @@ -269,6 +290,10 @@ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) return REDISMODULE_ERR; } + if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationBatched) != REDISMODULE_OK){ + return REDISMODULE_ERR; + } + if (with_key_events) { if(RedisModule_SubscribeToServerEvent(ctx, RedisModuleEvent_Key, KeySpace_ServerEventCallback) != REDISMODULE_OK){ return REDISMODULE_ERR; diff --git a/tests/unit/moduleapi/ksn_notify_coverage.md b/tests/unit/moduleapi/ksn_notify_coverage.md new file mode 100644 index 000000000..68f723411 --- /dev/null +++ b/tests/unit/moduleapi/ksn_notify_coverage.md @@ -0,0 +1,173 @@ +# Keyspace Notification Test Coverage Analysis + +This document analyzes the test coverage for keyspace notifications that affect +modules like RediSearch, which use `SetKeyMeta` in notification callbacks. + +## RediSearch Notification Events + +RediSearch's `HashNotificationCallback` handles the following events (from +`modules/redisearch/src/src/notifications.c`): + +```c +typedef enum { + _null_cmd, + hset_cmd, + hmset_cmd, + hsetnx_cmd, + hincrby_cmd, + hincrbyfloat_cmd, + hdel_cmd, + del_cmd, + set_cmd, + rename_from_cmd, + rename_to_cmd, + trimmed_cmd, + restore_cmd, + expire_cmd, + persist_cmd, + expired_cmd, + hexpire_cmd, + hpersist_cmd, + hexpired_cmd, + evicted_cmd, + change_cmd, + loaded_cmd, + copy_to_cmd, +} RedisCmd; +``` + +## Test Coverage Matrix + +Legend: +- ✅ = Already covered (before this PR) +- 🆕 = Added in this PR +- ❌ = Not covered + +### Hash Events (NOTIFY_HASH) + +| Event | Commands That Trigger | Coverage | Notes | +|-------|----------------------|----------|-------| +| `hset` | HSET, HMSET, HSETNX | ✅ Already covered | HSET, HMSET, HSETNX tests | +| `hset` | HSETEX | 🆕 **This PR** | HSETEX test | +| `hdel` | HDEL | 🆕 **This PR** | HDEL test | +| `hdel` | HGETDEL | 🆕 **This PR** | HGETDEL test | +| `hdel` | HGETEX (past timestamp), HSETEX (past timestamp), HEXPIRE (past timestamp) | 🆕 **This PR** | Covered via HGETEX/HSETEX/HEXPIRE tests | +| `hincrby` | HINCRBY | ✅ Already covered | HINCRBY test | +| `hincrbyfloat` | HINCRBYFLOAT | ✅ Already covered | HINCRBYFLOAT test | +| `hexpire` | HEXPIRE, HPEXPIRE, HEXPIREAT, HPEXPIREAT | 🆕 **This PR** | HEXPIRE test | +| `hexpire` | HGETEX (with EX/PX), HSETEX (with EX/PX) | 🆕 **This PR** | HGETEX/HSETEX tests | +| `hpersist` | HPERSIST | 🆕 **This PR** | HPERSIST test | +| `hpersist` | HGETEX (with PERSIST) | 🆕 **This PR** | HGETEX test | +| `hexpired` | Lazy field expiration, Active field expiration | 🆕 **This PR** | Hash field expiry test | + +### Generic Events (NOTIFY_GENERIC) + +| Event | Commands That Trigger | Coverage | Notes | +|-------|----------------------|----------|-------| +| `del` | DEL, UNLINK | ✅ Already covered | DEL test | +| `del` | Hash becomes empty | 🆕 **This PR** | Via HDEL/HGETDEL tests | +| `rename_from` | RENAME, RENAMENX | ✅ Already covered | RENAME test | +| `rename_to` | RENAME, RENAMENX | ✅ Already covered | RENAME test | +| `restore` | RESTORE | ✅ Already covered | RESTORE test | +| `expire` | EXPIRE, PEXPIRE, EXPIREAT, PEXPIREAT | ✅ Already covered | EXPIRE test | +| `expire` | SET (with EX/PX), GETEX (with EX/PX), SETEX, PSETEX | ⚠️ Partial | SET test covers some | +| `persist` | PERSIST | 🆕 **This PR** | PERSIST test | +| `copy_to` | COPY | 🆕 **This PR** | COPY test | +| `loaded` | RDB load (DEBUG RELOAD, server restart) | ✅ Already covered | DEBUG RELOAD test | + +### String Events (NOTIFY_STRING) + +| Event | Commands That Trigger | Coverage | Notes | +|-------|----------------------|----------|-------| +| `set` | SET, SETEX, PSETEX, SETNX, SETRANGE, etc. | ✅ Already covered | SET test | + +### Expired/Evicted Events + +| Event | Commands That Trigger | Coverage | Notes | +|-------|----------------------|----------|-------| +| `expired` | Key expiration (lazy or active) | ✅ Already covered | EXPIRE test waits for expiry | +| `evicted` | Memory eviction | ❌ Not tested | Requires maxmemory config | + +## Summary: What This PR Adds + +### New Tests Added in This PR + +| Test | Events Covered | Status Without Fix | +|------|----------------|-------------------| +| HSETEX | `hset`, `hexpire`, `hdel` | ❌ **CRASHES** | +| HGETDEL | `hdel`, `hexpired` | ❌ **CRASHES** | +| HGETEX | `hexpire`, `hpersist`, `hdel` | ❌ **CRASHES** | +| HDEL | `hdel` | ❌ **CRASHES** | +| HEXPIRE | `hexpire`, `hdel` | ❌ **CRASHES** | +| HPERSIST | `hpersist` | ✅ Passes | +| (field expiry) | `hexpired` | ✅ Passes | +| PERSIST | `persist` | ✅ Passes | +| COPY | `copy_to` | ✅ Passes | + +### Bug Fixes Required + +Commands that need fixing for use-after-reallocation when `SetKeyMeta` is called: +- `hsetexCommand` - accesses `o` after `notifyKeyspaceEvent` +- `hgetdelCommand` - accesses `o` after `notifyKeyspaceEvent` +- `hgetexCommand` - accesses `o` after `notifyKeyspaceEvent` +- `hdelCommand` - accesses `o` after `notifyKeyspaceEvent` +- `hexpireGenericCommand` - accesses `hashObj` after `notifyKeyspaceEvent` + +## Still Not Covered (Future Work) + +| Event | Command/Trigger | Reason | +|-------|-----------------|--------| +| `evicted` | Memory eviction | Requires maxmemory configuration | + +## Command to Event Mapping + +### Commands Already Covered (Before This PR) + +| Command | Event(s) Triggered | +|---------|-------------------| +| HSETNX | `hset` | +| HSET | `hset` | +| HMSET | `hset` | +| HINCRBY | `hincrby` | +| HINCRBYFLOAT | `hincrbyfloat` | +| SET | `set` | +| APPEND | `append` | +| INCR | `incrby` | +| INCRBY | `incrby` | +| INCRBYFLOAT | `incrbyfloat` | +| GETSET | `set` | +| SETRANGE | `setrange` | +| DEL | `del` | +| RENAME | `rename_from`, `rename_to` | +| RESTORE | `restore` | +| EXPIRE/PEXPIRE | `expire` | +| DEBUG RELOAD | `loaded` | + +### Commands Added in This PR + +| Command | Event(s) Triggered | +|---------|-------------------| +| HSETEX | `hset`, `hexpire`, `hdel` | +| HGETDEL | `hdel`, `hexpired` | +| HGETEX | `hexpire`, `hpersist`, `hdel` | +| HDEL | `hdel` | +| HEXPIRE/HPEXPIRE | `hexpire`, `hdel` | +| HPERSIST | `hpersist` | + +### Commands NOT Covered + +| Command | Event(s) Triggered | +|---------|-------------------| +| PERSIST | `persist` | +| COPY | `copy_to` | +| (field expiry wait) | `hexpired` | + +## Source Files Reference + +- Notification events: `src/notify.c` +- Hash notifications: `src/t_hash.c` +- String notifications: `src/t_string.c` +- Generic notifications: `src/db.c`, `src/expire.c` +- RediSearch handler: `modules/redisearch/src/src/notifications.c` +- Test module: `tests/modules/keymeta_notify.c` +- Test file: `tests/unit/moduleapi/ksn_notify_side_effect.tcl` diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index 31a466941..5636acfa0 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -148,6 +148,54 @@ tags "modules external:skip" { close_replication_stream $repl } + test {Test per-key post notification job fans out within a MULTI/EXEC} { + r flushall + set repl [attach_to_replication_stream] + + r multi + r set batched_a 1 + r set batched_b 2 + r set batched_c 3 + r exec + + assert_equal {batched_c batched_b batched_a} [r lrange batched_keys 0 -1] + + # The three SETs are one execution unit; the keyed post-notification + # job coalesces them and the callback fans out into three LPUSHes + # inside the same MULTI/EXEC propagation block. + assert_replication_stream $repl { + {multi} + {select *} + {set batched_a 1} + {set batched_b 2} + {set batched_c 3} + {lpush batched_keys batched_a} + {lpush batched_keys batched_b} + {lpush batched_keys batched_c} + {exec} + } + close_replication_stream $repl + } + + test {Test per-key post notification job from a multi-key command} { + r flushall + set repl [attach_to_replication_stream] + + r mset batched_a 1 batched_b 2 batched_c 3 + assert_equal {batched_c batched_b batched_a} [r lrange batched_keys 0 -1] + + assert_replication_stream $repl { + {multi} + {select *} + {mset batched_a 1 batched_b 2 batched_c 3} + {lpush batched_keys batched_a} + {lpush batched_keys batched_b} + {lpush batched_keys batched_c} + {exec} + } + close_replication_stream $repl + } + test {Test eviction} { r flushall set repl [attach_to_replication_stream] From 949099c0f78843f8ad2efbbb40ad5e42a4e523d2 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Thu, 21 May 2026 14:42:01 +0200 Subject: [PATCH 02/24] remove leaked file --- tests/unit/moduleapi/ksn_notify_coverage.md | 173 -------------------- 1 file changed, 173 deletions(-) delete mode 100644 tests/unit/moduleapi/ksn_notify_coverage.md diff --git a/tests/unit/moduleapi/ksn_notify_coverage.md b/tests/unit/moduleapi/ksn_notify_coverage.md deleted file mode 100644 index 68f723411..000000000 --- a/tests/unit/moduleapi/ksn_notify_coverage.md +++ /dev/null @@ -1,173 +0,0 @@ -# Keyspace Notification Test Coverage Analysis - -This document analyzes the test coverage for keyspace notifications that affect -modules like RediSearch, which use `SetKeyMeta` in notification callbacks. - -## RediSearch Notification Events - -RediSearch's `HashNotificationCallback` handles the following events (from -`modules/redisearch/src/src/notifications.c`): - -```c -typedef enum { - _null_cmd, - hset_cmd, - hmset_cmd, - hsetnx_cmd, - hincrby_cmd, - hincrbyfloat_cmd, - hdel_cmd, - del_cmd, - set_cmd, - rename_from_cmd, - rename_to_cmd, - trimmed_cmd, - restore_cmd, - expire_cmd, - persist_cmd, - expired_cmd, - hexpire_cmd, - hpersist_cmd, - hexpired_cmd, - evicted_cmd, - change_cmd, - loaded_cmd, - copy_to_cmd, -} RedisCmd; -``` - -## Test Coverage Matrix - -Legend: -- ✅ = Already covered (before this PR) -- 🆕 = Added in this PR -- ❌ = Not covered - -### Hash Events (NOTIFY_HASH) - -| Event | Commands That Trigger | Coverage | Notes | -|-------|----------------------|----------|-------| -| `hset` | HSET, HMSET, HSETNX | ✅ Already covered | HSET, HMSET, HSETNX tests | -| `hset` | HSETEX | 🆕 **This PR** | HSETEX test | -| `hdel` | HDEL | 🆕 **This PR** | HDEL test | -| `hdel` | HGETDEL | 🆕 **This PR** | HGETDEL test | -| `hdel` | HGETEX (past timestamp), HSETEX (past timestamp), HEXPIRE (past timestamp) | 🆕 **This PR** | Covered via HGETEX/HSETEX/HEXPIRE tests | -| `hincrby` | HINCRBY | ✅ Already covered | HINCRBY test | -| `hincrbyfloat` | HINCRBYFLOAT | ✅ Already covered | HINCRBYFLOAT test | -| `hexpire` | HEXPIRE, HPEXPIRE, HEXPIREAT, HPEXPIREAT | 🆕 **This PR** | HEXPIRE test | -| `hexpire` | HGETEX (with EX/PX), HSETEX (with EX/PX) | 🆕 **This PR** | HGETEX/HSETEX tests | -| `hpersist` | HPERSIST | 🆕 **This PR** | HPERSIST test | -| `hpersist` | HGETEX (with PERSIST) | 🆕 **This PR** | HGETEX test | -| `hexpired` | Lazy field expiration, Active field expiration | 🆕 **This PR** | Hash field expiry test | - -### Generic Events (NOTIFY_GENERIC) - -| Event | Commands That Trigger | Coverage | Notes | -|-------|----------------------|----------|-------| -| `del` | DEL, UNLINK | ✅ Already covered | DEL test | -| `del` | Hash becomes empty | 🆕 **This PR** | Via HDEL/HGETDEL tests | -| `rename_from` | RENAME, RENAMENX | ✅ Already covered | RENAME test | -| `rename_to` | RENAME, RENAMENX | ✅ Already covered | RENAME test | -| `restore` | RESTORE | ✅ Already covered | RESTORE test | -| `expire` | EXPIRE, PEXPIRE, EXPIREAT, PEXPIREAT | ✅ Already covered | EXPIRE test | -| `expire` | SET (with EX/PX), GETEX (with EX/PX), SETEX, PSETEX | ⚠️ Partial | SET test covers some | -| `persist` | PERSIST | 🆕 **This PR** | PERSIST test | -| `copy_to` | COPY | 🆕 **This PR** | COPY test | -| `loaded` | RDB load (DEBUG RELOAD, server restart) | ✅ Already covered | DEBUG RELOAD test | - -### String Events (NOTIFY_STRING) - -| Event | Commands That Trigger | Coverage | Notes | -|-------|----------------------|----------|-------| -| `set` | SET, SETEX, PSETEX, SETNX, SETRANGE, etc. | ✅ Already covered | SET test | - -### Expired/Evicted Events - -| Event | Commands That Trigger | Coverage | Notes | -|-------|----------------------|----------|-------| -| `expired` | Key expiration (lazy or active) | ✅ Already covered | EXPIRE test waits for expiry | -| `evicted` | Memory eviction | ❌ Not tested | Requires maxmemory config | - -## Summary: What This PR Adds - -### New Tests Added in This PR - -| Test | Events Covered | Status Without Fix | -|------|----------------|-------------------| -| HSETEX | `hset`, `hexpire`, `hdel` | ❌ **CRASHES** | -| HGETDEL | `hdel`, `hexpired` | ❌ **CRASHES** | -| HGETEX | `hexpire`, `hpersist`, `hdel` | ❌ **CRASHES** | -| HDEL | `hdel` | ❌ **CRASHES** | -| HEXPIRE | `hexpire`, `hdel` | ❌ **CRASHES** | -| HPERSIST | `hpersist` | ✅ Passes | -| (field expiry) | `hexpired` | ✅ Passes | -| PERSIST | `persist` | ✅ Passes | -| COPY | `copy_to` | ✅ Passes | - -### Bug Fixes Required - -Commands that need fixing for use-after-reallocation when `SetKeyMeta` is called: -- `hsetexCommand` - accesses `o` after `notifyKeyspaceEvent` -- `hgetdelCommand` - accesses `o` after `notifyKeyspaceEvent` -- `hgetexCommand` - accesses `o` after `notifyKeyspaceEvent` -- `hdelCommand` - accesses `o` after `notifyKeyspaceEvent` -- `hexpireGenericCommand` - accesses `hashObj` after `notifyKeyspaceEvent` - -## Still Not Covered (Future Work) - -| Event | Command/Trigger | Reason | -|-------|-----------------|--------| -| `evicted` | Memory eviction | Requires maxmemory configuration | - -## Command to Event Mapping - -### Commands Already Covered (Before This PR) - -| Command | Event(s) Triggered | -|---------|-------------------| -| HSETNX | `hset` | -| HSET | `hset` | -| HMSET | `hset` | -| HINCRBY | `hincrby` | -| HINCRBYFLOAT | `hincrbyfloat` | -| SET | `set` | -| APPEND | `append` | -| INCR | `incrby` | -| INCRBY | `incrby` | -| INCRBYFLOAT | `incrbyfloat` | -| GETSET | `set` | -| SETRANGE | `setrange` | -| DEL | `del` | -| RENAME | `rename_from`, `rename_to` | -| RESTORE | `restore` | -| EXPIRE/PEXPIRE | `expire` | -| DEBUG RELOAD | `loaded` | - -### Commands Added in This PR - -| Command | Event(s) Triggered | -|---------|-------------------| -| HSETEX | `hset`, `hexpire`, `hdel` | -| HGETDEL | `hdel`, `hexpired` | -| HGETEX | `hexpire`, `hpersist`, `hdel` | -| HDEL | `hdel` | -| HEXPIRE/HPEXPIRE | `hexpire`, `hdel` | -| HPERSIST | `hpersist` | - -### Commands NOT Covered - -| Command | Event(s) Triggered | -|---------|-------------------| -| PERSIST | `persist` | -| COPY | `copy_to` | -| (field expiry wait) | `hexpired` | - -## Source Files Reference - -- Notification events: `src/notify.c` -- Hash notifications: `src/t_hash.c` -- String notifications: `src/t_string.c` -- Generic notifications: `src/db.c`, `src/expire.c` -- RediSearch handler: `modules/redisearch/src/src/notifications.c` -- Test module: `tests/modules/keymeta_notify.c` -- Test file: `tests/unit/moduleapi/ksn_notify_side_effect.tcl` From c8c5974ef82c5c224de14b9f17934ed76f5eb1be Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Thu, 21 May 2026 17:57:46 +0200 Subject: [PATCH 03/24] change to proper per-key command handling --- src/module.c | 235 ++++++++++++--------- src/server.c | 6 + src/server.h | 1 + tests/unit/moduleapi/postnotifications.tcl | 23 +- 4 files changed, 151 insertions(+), 114 deletions(-) diff --git a/src/module.c b/src/module.c index 0d2c7f3f8..23a960715 100644 --- a/src/module.c +++ b/src/module.c @@ -328,43 +328,39 @@ typedef struct RedisModuleKeyspaceSubscriber { int active; } RedisModuleKeyspaceSubscriber; -typedef enum { - POST_EXEC_UNIT_JOB_SINGLE = 0, - POST_EXEC_UNIT_JOB_KEYED = 1, -} PostExecUnitJobType; +typedef struct RedisModulePostExecUnitJob { + /* The module subscribed to the event */ + RedisModule *module; + RedisModulePostNotificationJobFunc callback; + void *pd; + void (*free_pd)(void*); + int dbid; +} RedisModulePostExecUnitJob; -/* Per-key entry inside a keyed PostExecUnit job. The keyed job fans out and - * invokes its callback once per entry, in submission order. */ -typedef struct RedisModulePostExecUnitKeyedEntry { +/* Per-key entry inside a keyed post-notification job. The keyed job fans out + * and invokes its callback once per entry, in submission order. */ +typedef struct RedisModulePostKeyedNotificationEntry { RedisModuleString *key; /* Owned reference; freed after the callback runs. */ void *pd; void (*free_pd)(void*); -} RedisModulePostExecUnitKeyedEntry; +} RedisModulePostKeyedNotificationEntry; -typedef struct RedisModulePostExecUnitJob { - PostExecUnitJobType type; - /* The module subscribed to the event */ +/* A keyed post-notification job. Coalesces all keys submitted for the same + * (module, callback) pair during a single command into one job; fires at the + * tail of every call() so each sub-command boundary is observed. */ +typedef struct RedisModulePostKeyedNotificationJob { RedisModule *module; + RedisModulePostNotificationJobPerKeyFunc callback; + list *entries; /* list of RedisModulePostKeyedNotificationEntry* */ int dbid; - union { - struct { - RedisModulePostNotificationJobFunc callback; - void *pd; - void (*free_pd)(void*); - } single; - struct { - RedisModulePostNotificationJobPerKeyFunc callback; - list *entries; /* list of RedisModulePostExecUnitKeyedEntry* */ - } keyed; - } u; -} RedisModulePostExecUnitJob; +} RedisModulePostKeyedNotificationJob; -/* Index key used to coalesce keyed post-exec-unit jobs by (module, callback) - * while the current execution unit is still in progress. */ +/* Index key used to coalesce keyed post-notification jobs by (module, callback) + * while the current command is still in progress. */ typedef struct { RedisModule *module; RedisModulePostNotificationJobPerKeyFunc callback; -} PostExecUnitKeyedJobIndexKey; +} PostKeyedNotificationJobIndexKey; /* The module keyspace notification subscribers list */ static list *moduleKeyspaceSubscribers; @@ -374,14 +370,19 @@ static list *moduleKeyspaceSubscribers; static int moduleKeyspaceSubscribersTypes = 0; static int moduleKeyspaceSubscribersWithSubkeysTypes = 0; -/* The module post keyspace jobs list */ +/* The module post keyspace jobs list (single-shot jobs, fired once at the end + * of the outermost execution unit). */ static list *modulePostExecUnitJobs; -/* Index from (module, per-key callback) -> listNode* in modulePostExecUnitJobs. - * Used so that submitting a second key for an already-pending keyed job appends - * to that job's entry list rather than enqueuing a new job. Reset to empty at - * the end of every drain. */ -static dict *modulePostExecUnitKeyedJobIndex; +/* The module per-key post-notification jobs list (fired at the tail of every + * call(), so each sub-command boundary inside MULTI/EXEC is observed). */ +static list *modulePostKeyedNotificationJobs; + +/* Index from (module, per-key callback) -> RedisModulePostKeyedNotificationJob* + * in modulePostKeyedNotificationJobs. Used so that submitting a second key for + * an already-pending keyed job appends to that job's entry list rather than + * enqueuing a new job. Reset to empty at the end of every drain. */ +static dict *modulePostKeyedNotificationJobIndex; /* Data structures related to the exported dictionary data structure. */ typedef struct RedisModuleDict { @@ -9470,43 +9471,63 @@ void firePostExecutionUnitJobs(void) { moduleCreateContext(&ctx, job->module, REDISMODULE_CTX_TEMP_CLIENT); selectDb(ctx.client, job->dbid); - if (job->type == POST_EXEC_UNIT_JOB_SINGLE) { - job->u.single.callback(&ctx, job->u.single.pd); - if (job->u.single.free_pd) job->u.single.free_pd(job->u.single.pd); - } else { - /* Keyed job: fan out the callback over each collected key, sharing - * the same context. The index entry is removed so that any keys - * submitted *after* this point (e.g. from a nested KSN triggered - * inside the callback) start a fresh keyed job. */ - PostExecUnitKeyedJobIndexKey idx = { - .module = job->module, - .callback = job->u.keyed.callback, - }; - dictDelete(modulePostExecUnitKeyedJobIndex, &idx); - - listIter li; - listNode *eln; - listRewind(job->u.keyed.entries, &li); - while ((eln = listNext(&li)) != NULL) { - RedisModulePostExecUnitKeyedEntry *e = listNodeValue(eln); - job->u.keyed.callback(&ctx, e->key, e->pd); - if (e->free_pd) e->free_pd(e->pd); - decrRefCount(e->key); - zfree(e); - } - listRelease(job->u.keyed.entries); - } + job->callback(&ctx, job->pd); + if (job->free_pd) job->free_pd(job->pd); moduleFreeContext(&ctx); zfree(job); } - /* Defensive: any stale index entries (shouldn't be possible since every - * keyed job we enqueued got drained above, but cheap insurance). */ - if (dictSize(modulePostExecUnitKeyedJobIndex) > 0) - dictEmpty(modulePostExecUnitKeyedJobIndex, NULL); exitExecutionUnit(); } +/* Drain the keyed post-notification jobs queued during the current call(). + * Invoked at the tail of every call() (see afterCommand), so callbacks fire + * between sub-commands inside MULTI/EXEC. Uses a static reentrance guard + * since the per-call() hook bypasses the execution_nesting gating used by + * firePostExecutionUnitJobs. */ +void firePostKeyedNotificationJobs(void) { + static int firing = 0; + if (firing) return; + if (listLength(modulePostKeyedNotificationJobs) == 0) return; + firing = 1; + enterExecutionUnit(0, 0); + while (listLength(modulePostKeyedNotificationJobs) > 0) { + listNode *ln = listFirst(modulePostKeyedNotificationJobs); + RedisModulePostKeyedNotificationJob *job = listNodeValue(ln); + listDelNode(modulePostKeyedNotificationJobs, ln); + + /* Remove the index entry up front so that any keys submitted from + * within the callback (e.g. via a nested KSN triggered by RM_Call) + * start a fresh keyed job. */ + PostKeyedNotificationJobIndexKey idx = { + .module = job->module, + .callback = job->callback, + }; + dictDelete(modulePostKeyedNotificationJobIndex, &idx); + + RedisModuleCtx ctx; + moduleCreateContext(&ctx, job->module, REDISMODULE_CTX_TEMP_CLIENT); + selectDb(ctx.client, job->dbid); + + listIter li; + listNode *eln; + listRewind(job->entries, &li); + while ((eln = listNext(&li)) != NULL) { + RedisModulePostKeyedNotificationEntry *e = listNodeValue(eln); + job->callback(&ctx, e->key, e->pd); + if (e->free_pd) e->free_pd(e->pd); + decrRefCount(e->key); + zfree(e); + } + listRelease(job->entries); + + moduleFreeContext(&ctx); + zfree(job); + } + exitExecutionUnit(); + firing = 0; +} + /* When running inside a key space notification callback, it is dangerous and highly discouraged to perform any write * operation (See `RM_SubscribeToKeyspaceEvents`). In order to still perform write actions in this scenario, * Redis provides `RM_AddPostNotificationJob` API. The API allows to register a job callback which Redis will call @@ -9529,66 +9550,77 @@ int RM_AddPostNotificationJob(RedisModuleCtx *ctx, RedisModulePostNotificationJo return REDISMODULE_ERR; } RedisModulePostExecUnitJob *job = zmalloc(sizeof(*job)); - job->type = POST_EXEC_UNIT_JOB_SINGLE; job->module = ctx->module; + job->callback = callback; + job->pd = privdata; + job->free_pd = free_privdata; job->dbid = ctx->client->db->id; - job->u.single.callback = callback; - job->u.single.pd = privdata; - job->u.single.free_pd = free_privdata; listAddNodeTail(modulePostExecUnitJobs, job); return REDISMODULE_OK; } -/* Sibling of `RM_AddPostNotificationJob` that fans out per-key. Multiple - * submissions of the same (module, callback) pair within a single execution - * unit are coalesced into a single job whose callback is invoked once per - * collected key, in submission order. This lets a module react to several - * keys touched by the same MULTI/EXEC in one atomic propagation block. +/* Sibling of `RM_AddPostNotificationJob` that fans out per-key. The callback + * is invoked at the tail of the currently executing command (and at the tail + * of every sub-command inside MULTI/EXEC), so a module can observe per-key + * effects before the next sub-command runs. Multiple submissions of the same + * (module, callback) pair within a single command are coalesced; the callback + * is invoked once per collected key, in submission order. * - * Refusal rules, dbid pinning, replication atomicity, and re-entrancy - * semantics are identical to `RM_AddPostNotificationJob`. + * Only commands that touch exactly one key are supported. If called while the + * current command touches zero or more than one key, the registration is + * refused with REDISMODULE_ERR. This avoids the ambiguity of "per-key" firing + * inside multi-key commands (MSET, SUNIONSTORE, ...). * * `key` must be a valid RedisModuleString. The implementation takes its own * reference; the caller retains ownership of its own reference and may free * it at any time. `free_pd` may be NULL. * * Return REDISMODULE_OK on success and REDISMODULE_ERR if called while loading - * data from disk (AOF or RDB) or on a read-only replica. */ + * data from disk (AOF or RDB), on a read-only replica, or if the currently + * executing command does not touch exactly one key. */ int RM_AddPostNotificationJobForKey(RedisModuleCtx *ctx, RedisModulePostNotificationJobPerKeyFunc callback, RedisModuleString *key, void *privdata, void (*free_privdata)(void*)) { if (server.loading || (server.masterhost && server.repl_slave_ro)) { return REDISMODULE_ERR; } - RedisModulePostExecUnitKeyedEntry *entry = zmalloc(sizeof(*entry)); + /* Restrict to single-key commands. */ + client *ec = server.executing_client; + if (!ec || !ec->cmd) return REDISMODULE_ERR; + getKeysResult result = GETKEYS_RESULT_INIT; + getKeysFromCommand(ec->cmd, ec->argv, ec->argc, &result); + int numkeys = result.numkeys; + getKeysFreeResult(&result); + if (numkeys != 1) return REDISMODULE_ERR; + + RedisModulePostKeyedNotificationEntry *entry = zmalloc(sizeof(*entry)); entry->key = key; incrRefCount(entry->key); entry->pd = privdata; entry->free_pd = free_privdata; - PostExecUnitKeyedJobIndexKey idx_key = { + PostKeyedNotificationJobIndexKey idx_key = { .module = ctx->module, .callback = callback, }; - dictEntry *de = dictFind(modulePostExecUnitKeyedJobIndex, &idx_key); + dictEntry *de = dictFind(modulePostKeyedNotificationJobIndex, &idx_key); if (de) { - RedisModulePostExecUnitJob *job = dictGetVal(de); - listAddNodeTail(job->u.keyed.entries, entry); + RedisModulePostKeyedNotificationJob *job = dictGetVal(de); + listAddNodeTail(job->entries, entry); return REDISMODULE_OK; } - RedisModulePostExecUnitJob *job = zmalloc(sizeof(*job)); - job->type = POST_EXEC_UNIT_JOB_KEYED; + RedisModulePostKeyedNotificationJob *job = zmalloc(sizeof(*job)); job->module = ctx->module; + job->callback = callback; + job->entries = listCreate(); job->dbid = ctx->client->db->id; - job->u.keyed.callback = callback; - job->u.keyed.entries = listCreate(); - listAddNodeTail(job->u.keyed.entries, entry); - listAddNodeTail(modulePostExecUnitJobs, job); + listAddNodeTail(job->entries, entry); + listAddNodeTail(modulePostKeyedNotificationJobs, job); - PostExecUnitKeyedJobIndexKey *idx = zmalloc(sizeof(*idx)); + PostKeyedNotificationJobIndexKey *idx = zmalloc(sizeof(*idx)); *idx = idx_key; - dictAdd(modulePostExecUnitKeyedJobIndex, idx, job); + dictAdd(modulePostKeyedNotificationJobIndex, idx, job); return REDISMODULE_OK; } @@ -13111,29 +13143,29 @@ dictType moduleAPIDictType = { NULL /* allow to expand */ }; -static uint64_t postExecUnitKeyedJobIndexHash(const void *key) { - return dictGenHashFunction(key, sizeof(PostExecUnitKeyedJobIndexKey)); +static uint64_t postKeyedNotificationJobIndexHash(const void *key) { + return dictGenHashFunction(key, sizeof(PostKeyedNotificationJobIndexKey)); } -static int postExecUnitKeyedJobIndexCompare(dictCmpCache *cache, const void *k1, const void *k2) { +static int postKeyedNotificationJobIndexCompare(dictCmpCache *cache, const void *k1, const void *k2) { UNUSED(cache); - const PostExecUnitKeyedJobIndexKey *a = k1, *b = k2; + const PostKeyedNotificationJobIndexKey *a = k1, *b = k2; return a->module == b->module && a->callback == b->callback; } -static void postExecUnitKeyedJobIndexKeyDtor(dict *d, void *k) { +static void postKeyedNotificationJobIndexKeyDtor(dict *d, void *k) { UNUSED(d); zfree(k); } -dictType postExecUnitKeyedJobIndexDictType = { - postExecUnitKeyedJobIndexHash, /* hash function */ - NULL, /* key dup */ - NULL, /* val dup */ - postExecUnitKeyedJobIndexCompare, /* key compare */ - postExecUnitKeyedJobIndexKeyDtor, /* key destructor */ - NULL, /* val destructor */ - NULL /* allow to expand */ +dictType postKeyedNotificationJobIndexDictType = { + postKeyedNotificationJobIndexHash, /* hash function */ + NULL, /* key dup */ + NULL, /* val dup */ + postKeyedNotificationJobIndexCompare, /* key compare */ + postKeyedNotificationJobIndexKeyDtor, /* key destructor */ + NULL, /* val destructor */ + NULL /* allow to expand */ }; int moduleRegisterApi(const char *funcname, void *funcptr) { @@ -13176,7 +13208,8 @@ void moduleInitModulesSystem(void) { moduleKeyspaceSubscribers = listCreate(); modulePostExecUnitJobs = listCreate(); - modulePostExecUnitKeyedJobIndex = dictCreate(&postExecUnitKeyedJobIndexDictType); + modulePostKeyedNotificationJobs = listCreate(); + modulePostKeyedNotificationJobIndex = dictCreate(&postKeyedNotificationJobIndexDictType); /* Set up filter list */ moduleCommandFilters = listCreate(); diff --git a/src/server.c b/src/server.c index b1bafa003..fc4d4bec7 100644 --- a/src/server.c +++ b/src/server.c @@ -4248,6 +4248,12 @@ void rejectCommandFormat(client *c, const char *fmt, ...) { /* This is called after a command in call, we can do some maintenance job in it. */ void afterCommand(client *c) { + /* Fire keyed post-notification jobs first, before any propagation. These + * fire after every command (including each sub-command inside MULTI/EXEC), + * regardless of execution-unit nesting, so a module can react to a key + * before the next sub-command observes it. */ + firePostKeyedNotificationJobs(); + /* Should be done before trackingHandlePendingKeyInvalidations so that we * reply to client before invalidating cache (makes more sense) */ postExecutionUnitOperations(); diff --git a/src/server.h b/src/server.h index 2a6fa5fcb..6bb147772 100644 --- a/src/server.h +++ b/src/server.h @@ -3121,6 +3121,7 @@ int moduleTryAcquireGIL(void); void moduleReleaseGIL(void); void moduleNotifyKeyspaceEvent(int type, const char *event, robj *key, int dbid, robj **subkeys, int count); void firePostExecutionUnitJobs(void); +void firePostKeyedNotificationJobs(void); void moduleCallCommandFilters(client *c); void modulePostExecutionUnitOperations(void); void ModuleForkDoneHandler(int exitcode, int bysignal); diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index 5636acfa0..4fb0c400b 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -148,7 +148,7 @@ tags "modules external:skip" { close_replication_stream $repl } - test {Test per-key post notification job fans out within a MULTI/EXEC} { + test {Test per-key post notification job fires between MULTI/EXEC sub-commands} { r flushall set repl [attach_to_replication_stream] @@ -158,40 +158,37 @@ tags "modules external:skip" { r set batched_c 3 r exec + # Each SET's keyed callback fires at the tail of its own sub-command, + # before the next sub-command runs, so LPUSHes are interleaved with + # the SETs inside the MULTI/EXEC propagation block. assert_equal {batched_c batched_b batched_a} [r lrange batched_keys 0 -1] - # The three SETs are one execution unit; the keyed post-notification - # job coalesces them and the callback fans out into three LPUSHes - # inside the same MULTI/EXEC propagation block. assert_replication_stream $repl { {multi} {select *} {set batched_a 1} - {set batched_b 2} - {set batched_c 3} {lpush batched_keys batched_a} + {set batched_b 2} {lpush batched_keys batched_b} + {set batched_c 3} {lpush batched_keys batched_c} {exec} } close_replication_stream $repl } - test {Test per-key post notification job from a multi-key command} { + test {Per-key post notification job is refused on multi-key commands} { r flushall set repl [attach_to_replication_stream] + # MSET touches multiple keys; AddPostNotificationJobForKey must refuse + # the registration from KSN, so no LPUSH side-effect is propagated. r mset batched_a 1 batched_b 2 batched_c 3 - assert_equal {batched_c batched_b batched_a} [r lrange batched_keys 0 -1] + assert_equal {} [r lrange batched_keys 0 -1] assert_replication_stream $repl { - {multi} {select *} {mset batched_a 1 batched_b 2 batched_c 3} - {lpush batched_keys batched_a} - {lpush batched_keys batched_b} - {lpush batched_keys batched_c} - {exec} } close_replication_stream $repl } From 81cce455f3350e973bcb9ff8fb3685a63b3bf9ad Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Thu, 21 May 2026 18:04:59 +0200 Subject: [PATCH 04/24] remove useless index --- src/module.c | 116 +++++++-------------------------------------------- 1 file changed, 16 insertions(+), 100 deletions(-) diff --git a/src/module.c b/src/module.c index 23a960715..4c7c6e6fc 100644 --- a/src/module.c +++ b/src/module.c @@ -337,31 +337,17 @@ typedef struct RedisModulePostExecUnitJob { int dbid; } RedisModulePostExecUnitJob; -/* Per-key entry inside a keyed post-notification job. The keyed job fans out - * and invokes its callback once per entry, in submission order. */ -typedef struct RedisModulePostKeyedNotificationEntry { - RedisModuleString *key; /* Owned reference; freed after the callback runs. */ - void *pd; - void (*free_pd)(void*); -} RedisModulePostKeyedNotificationEntry; - -/* A keyed post-notification job. Coalesces all keys submitted for the same - * (module, callback) pair during a single command into one job; fires at the - * tail of every call() so each sub-command boundary is observed. */ +/* A keyed post-notification job. Fires at the tail of every call() so each + * sub-command boundary is observed; jobs fire in submission order. */ typedef struct RedisModulePostKeyedNotificationJob { RedisModule *module; RedisModulePostNotificationJobPerKeyFunc callback; - list *entries; /* list of RedisModulePostKeyedNotificationEntry* */ + RedisModuleString *key; /* Owned reference; freed after the callback runs. */ + void *pd; + void (*free_pd)(void*); int dbid; } RedisModulePostKeyedNotificationJob; -/* Index key used to coalesce keyed post-notification jobs by (module, callback) - * while the current command is still in progress. */ -typedef struct { - RedisModule *module; - RedisModulePostNotificationJobPerKeyFunc callback; -} PostKeyedNotificationJobIndexKey; - /* The module keyspace notification subscribers list */ static list *moduleKeyspaceSubscribers; @@ -378,12 +364,6 @@ static list *modulePostExecUnitJobs; * call(), so each sub-command boundary inside MULTI/EXEC is observed). */ static list *modulePostKeyedNotificationJobs; -/* Index from (module, per-key callback) -> RedisModulePostKeyedNotificationJob* - * in modulePostKeyedNotificationJobs. Used so that submitting a second key for - * an already-pending keyed job appends to that job's entry list rather than - * enqueuing a new job. Reset to empty at the end of every drain. */ -static dict *modulePostKeyedNotificationJobIndex; - /* Data structures related to the exported dictionary data structure. */ typedef struct RedisModuleDict { rax *rax; /* The radix tree. */ @@ -9496,30 +9476,13 @@ void firePostKeyedNotificationJobs(void) { RedisModulePostKeyedNotificationJob *job = listNodeValue(ln); listDelNode(modulePostKeyedNotificationJobs, ln); - /* Remove the index entry up front so that any keys submitted from - * within the callback (e.g. via a nested KSN triggered by RM_Call) - * start a fresh keyed job. */ - PostKeyedNotificationJobIndexKey idx = { - .module = job->module, - .callback = job->callback, - }; - dictDelete(modulePostKeyedNotificationJobIndex, &idx); - RedisModuleCtx ctx; moduleCreateContext(&ctx, job->module, REDISMODULE_CTX_TEMP_CLIENT); selectDb(ctx.client, job->dbid); - listIter li; - listNode *eln; - listRewind(job->entries, &li); - while ((eln = listNext(&li)) != NULL) { - RedisModulePostKeyedNotificationEntry *e = listNodeValue(eln); - job->callback(&ctx, e->key, e->pd); - if (e->free_pd) e->free_pd(e->pd); - decrRefCount(e->key); - zfree(e); - } - listRelease(job->entries); + job->callback(&ctx, job->key, job->pd); + if (job->free_pd) job->free_pd(job->pd); + decrRefCount(job->key); moduleFreeContext(&ctx); zfree(job); @@ -9560,12 +9523,10 @@ int RM_AddPostNotificationJob(RedisModuleCtx *ctx, RedisModulePostNotificationJo return REDISMODULE_OK; } -/* Sibling of `RM_AddPostNotificationJob` that fans out per-key. The callback - * is invoked at the tail of the currently executing command (and at the tail - * of every sub-command inside MULTI/EXEC), so a module can observe per-key - * effects before the next sub-command runs. Multiple submissions of the same - * (module, callback) pair within a single command are coalesced; the callback - * is invoked once per collected key, in submission order. +/* Sibling of `RM_AddPostNotificationJob` that fires per-key. The callback is + * invoked at the tail of the currently executing command (and at the tail of + * every sub-command inside MULTI/EXEC), so a module can observe per-key + * effects before the next sub-command runs. Jobs fire in submission order. * * Only commands that touch exactly one key are supported. If called while the * current command touches zero or more than one key, the registration is @@ -9593,34 +9554,15 @@ int RM_AddPostNotificationJobForKey(RedisModuleCtx *ctx, RedisModulePostNotifica getKeysFreeResult(&result); if (numkeys != 1) return REDISMODULE_ERR; - RedisModulePostKeyedNotificationEntry *entry = zmalloc(sizeof(*entry)); - entry->key = key; - incrRefCount(entry->key); - entry->pd = privdata; - entry->free_pd = free_privdata; - - PostKeyedNotificationJobIndexKey idx_key = { - .module = ctx->module, - .callback = callback, - }; - dictEntry *de = dictFind(modulePostKeyedNotificationJobIndex, &idx_key); - if (de) { - RedisModulePostKeyedNotificationJob *job = dictGetVal(de); - listAddNodeTail(job->entries, entry); - return REDISMODULE_OK; - } - RedisModulePostKeyedNotificationJob *job = zmalloc(sizeof(*job)); job->module = ctx->module; job->callback = callback; - job->entries = listCreate(); + job->key = key; + incrRefCount(job->key); + job->pd = privdata; + job->free_pd = free_privdata; job->dbid = ctx->client->db->id; - listAddNodeTail(job->entries, entry); listAddNodeTail(modulePostKeyedNotificationJobs, job); - - PostKeyedNotificationJobIndexKey *idx = zmalloc(sizeof(*idx)); - *idx = idx_key; - dictAdd(modulePostKeyedNotificationJobIndex, idx, job); return REDISMODULE_OK; } @@ -13143,31 +13085,6 @@ dictType moduleAPIDictType = { NULL /* allow to expand */ }; -static uint64_t postKeyedNotificationJobIndexHash(const void *key) { - return dictGenHashFunction(key, sizeof(PostKeyedNotificationJobIndexKey)); -} - -static int postKeyedNotificationJobIndexCompare(dictCmpCache *cache, const void *k1, const void *k2) { - UNUSED(cache); - const PostKeyedNotificationJobIndexKey *a = k1, *b = k2; - return a->module == b->module && a->callback == b->callback; -} - -static void postKeyedNotificationJobIndexKeyDtor(dict *d, void *k) { - UNUSED(d); - zfree(k); -} - -dictType postKeyedNotificationJobIndexDictType = { - postKeyedNotificationJobIndexHash, /* hash function */ - NULL, /* key dup */ - NULL, /* val dup */ - postKeyedNotificationJobIndexCompare, /* key compare */ - postKeyedNotificationJobIndexKeyDtor, /* key destructor */ - NULL, /* val destructor */ - NULL /* allow to expand */ -}; - int moduleRegisterApi(const char *funcname, void *funcptr) { return dictAdd(server.moduleapi, (char*)funcname, funcptr); } @@ -13209,7 +13126,6 @@ void moduleInitModulesSystem(void) { modulePostExecUnitJobs = listCreate(); modulePostKeyedNotificationJobs = listCreate(); - modulePostKeyedNotificationJobIndex = dictCreate(&postKeyedNotificationJobIndexDictType); /* Set up filter list */ moduleCommandFilters = listCreate(); From 9624998d8ce3999056a3021a5556ab1c61662451 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Thu, 21 May 2026 18:33:30 +0200 Subject: [PATCH 05/24] protect against re-entrance avoiding stack overflow --- src/module.c | 13 ++++--- src/server.c | 1 + src/server.h | 2 ++ tests/modules/postnotifications.c | 42 ++++++++++++++++++++++ tests/unit/moduleapi/postnotifications.tcl | 18 ++++++++++ 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/module.c b/src/module.c index 4c7c6e6fc..07bc9946d 100644 --- a/src/module.c +++ b/src/module.c @@ -9462,14 +9462,13 @@ void firePostExecutionUnitJobs(void) { /* Drain the keyed post-notification jobs queued during the current call(). * Invoked at the tail of every call() (see afterCommand), so callbacks fire - * between sub-commands inside MULTI/EXEC. Uses a static reentrance guard - * since the per-call() hook bypasses the execution_nesting gating used by - * firePostExecutionUnitJobs. */ + * between sub-commands inside MULTI/EXEC. Uses server.firing_keyed_post_notif_jobs + * as a reentrance guard, since the per-call() hook bypasses the execution_nesting + * gating used by firePostExecutionUnitJobs. */ void firePostKeyedNotificationJobs(void) { - static int firing = 0; - if (firing) return; + if (server.firing_keyed_post_notif_jobs) return; if (listLength(modulePostKeyedNotificationJobs) == 0) return; - firing = 1; + server.firing_keyed_post_notif_jobs = 1; enterExecutionUnit(0, 0); while (listLength(modulePostKeyedNotificationJobs) > 0) { listNode *ln = listFirst(modulePostKeyedNotificationJobs); @@ -9488,7 +9487,7 @@ void firePostKeyedNotificationJobs(void) { zfree(job); } exitExecutionUnit(); - firing = 0; + server.firing_keyed_post_notif_jobs = 0; } /* When running inside a key space notification callback, it is dangerous and highly discouraged to perform any write diff --git a/src/server.c b/src/server.c index fc4d4bec7..fd9550084 100644 --- a/src/server.c +++ b/src/server.c @@ -2972,6 +2972,7 @@ void initServer(void) { server.errors = raxNew(); server.errors_enabled = 1; server.execution_nesting = 0; + server.firing_keyed_post_notif_jobs = 0; server.clients = listCreate(); server.clients_index = raxNew(); server.clients_to_close = listCreate(); diff --git a/src/server.h b/src/server.h index 6bb147772..a466c3fc8 100644 --- a/src/server.h +++ b/src/server.h @@ -2062,6 +2062,8 @@ struct redisServer { int execution_nesting; /* Execution nesting level. * e.g. call(), async module stuff (timers, events, etc.), * cron stuff (active expire, eviction) */ + int firing_keyed_post_notif_jobs; /* Re-entrance guard while + * firePostKeyedNotificationJobs is draining. */ rax *clients_index; /* Active clients dictionary by client ID. */ uint32_t paused_actions; /* Bitmask of actions that are currently paused */ list *postponed_clients; /* List of postponed clients */ diff --git a/tests/modules/postnotifications.c b/tests/modules/postnotifications.c index 9ae316363..6fd323fa7 100644 --- a/tests/modules/postnotifications.c +++ b/tests/modules/postnotifications.c @@ -70,6 +70,44 @@ static int KeySpace_NotificationBatched(RedisModuleCtx *ctx, int type, const cha return REDISMODULE_OK; } +/* Re-entrance probe. The "outer" branch sets a marker, performs a nested + * RM_Call (which itself triggers another keyed-job registration via KSN), then + * clears the marker. If the firing function were to re-enter while the outer + * callback is still on the stack, the "inner" branch would observe marker==1 + * and report REENTRANCE_DETECTED. With the guard in place, the inner job runs + * only after the outer callback returns, so marker is always 0. */ +static int reentrance_in_outer_callback = 0; + +static void KeySpace_PostNotificationReentranceProbe(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + const char *key_str = RedisModule_StringPtrLen(key, NULL); + RedisModuleCallReply *rep; + + if (strcmp(key_str, "reentrant_outer") == 0) { + reentrance_in_outer_callback = 1; + rep = RedisModule_Call(ctx, "set", "!cc", "reentrant_inner", "1"); + if (rep) RedisModule_FreeCallReply(rep); + reentrance_in_outer_callback = 0; + rep = RedisModule_Call(ctx, "lpush", "!cc", "reentrance_log", "outer_done"); + if (rep) RedisModule_FreeCallReply(rep); + } else if (strcmp(key_str, "reentrant_inner") == 0) { + const char *marker = reentrance_in_outer_callback ? "REENTRANCE_DETECTED" : "inner_after_outer"; + rep = RedisModule_Call(ctx, "lpush", "!cc", "reentrance_log", marker); + if (rep) RedisModule_FreeCallReply(rep); + } +} + +static int KeySpace_NotificationReentrance(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "reentrant_", 10) != 0) return REDISMODULE_OK; + + RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationReentranceProbe, key, NULL, NULL); + return REDISMODULE_OK; +} + static int KeySpace_NotificationExpired(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ REDISMODULE_NOT_USED(type); REDISMODULE_NOT_USED(event); @@ -294,6 +332,10 @@ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) return REDISMODULE_ERR; } + if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationReentrance) != REDISMODULE_OK){ + return REDISMODULE_ERR; + } + if (with_key_events) { if(RedisModule_SubscribeToServerEvent(ctx, RedisModuleEvent_Key, KeySpace_ServerEventCallback) != REDISMODULE_OK){ return REDISMODULE_ERR; diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index 4fb0c400b..a47bb315d 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -177,6 +177,24 @@ tags "modules external:skip" { close_replication_stream $repl } + test {Per-key callback does not re-enter firing while a nested RM_Call is in flight} { + r flushall + + # SET reentrant_outer triggers a keyed-job registration. + # That callback sets a marker and issues an internal SET on + # reentrant_inner, which registers another keyed job. If the + # firing function re-entered while still inside the outer + # callback, the inner branch would observe the marker set and + # log REENTRANCE_DETECTED. With the guard, the inner job is + # picked up by the outer drain only after the outer callback + # has returned and the marker has been cleared. + r set reentrant_outer 1 + + set log [r lrange reentrance_log 0 -1] + assert_equal -1 [lsearch $log "REENTRANCE_DETECTED"] + assert_equal {inner_after_outer outer_done} $log + } + test {Per-key post notification job is refused on multi-key commands} { r flushall set repl [attach_to_replication_stream] From 0957c25dd8e0146873cd4a30ec5932410d14ac2b Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Thu, 21 May 2026 18:46:16 +0200 Subject: [PATCH 06/24] separate into new test suite --- runtest-moduleapi | 1 + tests/modules/Makefile | 1 + tests/modules/postnotifications.c | 67 ----------- tests/modules/postnotifications_perkey.c | 113 ++++++++++++++++++ tests/unit/moduleapi/postnotifications.tcl | 63 ---------- .../moduleapi/postnotifications_perkey.tcl | 70 +++++++++++ 6 files changed, 185 insertions(+), 130 deletions(-) create mode 100644 tests/modules/postnotifications_perkey.c create mode 100644 tests/unit/moduleapi/postnotifications_perkey.tcl diff --git a/runtest-moduleapi b/runtest-moduleapi index 2e4109390..bcb53c653 100755 --- a/runtest-moduleapi +++ b/runtest-moduleapi @@ -58,6 +58,7 @@ $TCLSH tests/test_helper.tcl \ --single unit/moduleapi/publish \ --single unit/moduleapi/usercall \ --single unit/moduleapi/postnotifications \ +--single unit/moduleapi/postnotifications_perkey \ --single unit/moduleapi/async_rm_call \ --single unit/moduleapi/moduleauth \ --single unit/moduleapi/rdbloadsave \ diff --git a/tests/modules/Makefile b/tests/modules/Makefile index 0659497cf..35be8fb64 100644 --- a/tests/modules/Makefile +++ b/tests/modules/Makefile @@ -79,6 +79,7 @@ TEST_MODULES = \ publish.so \ usercall.so \ postnotifications.so \ + postnotifications_perkey.so \ moduleauthtwo.so \ rdbloadsave.so \ crash.so \ diff --git a/tests/modules/postnotifications.c b/tests/modules/postnotifications.c index 6fd323fa7..96fb85918 100644 --- a/tests/modules/postnotifications.c +++ b/tests/modules/postnotifications.c @@ -49,65 +49,6 @@ static void KeySpace_PostNotificationString(RedisModuleCtx *ctx, void *pd) { RedisModule_FreeCallReply(rep); } -/* Per-key post-notification callback: appends each batched key to a single - * list, so the test can assert all keys touched in one execution unit fan - * out into the same MULTI/EXEC replication block. */ -static void KeySpace_PostNotificationBatchedKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { - REDISMODULE_NOT_USED(pd); - RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "batched_keys", key); - RedisModule_FreeCallReply(rep); -} - -static int KeySpace_NotificationBatched(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); - - const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "batched_", 8) != 0) return REDISMODULE_OK; - if (strcmp(key_str, "batched_keys") == 0) return REDISMODULE_OK; /* skip our sink list */ - - RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationBatchedKey, key, NULL, NULL); - return REDISMODULE_OK; -} - -/* Re-entrance probe. The "outer" branch sets a marker, performs a nested - * RM_Call (which itself triggers another keyed-job registration via KSN), then - * clears the marker. If the firing function were to re-enter while the outer - * callback is still on the stack, the "inner" branch would observe marker==1 - * and report REENTRANCE_DETECTED. With the guard in place, the inner job runs - * only after the outer callback returns, so marker is always 0. */ -static int reentrance_in_outer_callback = 0; - -static void KeySpace_PostNotificationReentranceProbe(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { - REDISMODULE_NOT_USED(pd); - const char *key_str = RedisModule_StringPtrLen(key, NULL); - RedisModuleCallReply *rep; - - if (strcmp(key_str, "reentrant_outer") == 0) { - reentrance_in_outer_callback = 1; - rep = RedisModule_Call(ctx, "set", "!cc", "reentrant_inner", "1"); - if (rep) RedisModule_FreeCallReply(rep); - reentrance_in_outer_callback = 0; - rep = RedisModule_Call(ctx, "lpush", "!cc", "reentrance_log", "outer_done"); - if (rep) RedisModule_FreeCallReply(rep); - } else if (strcmp(key_str, "reentrant_inner") == 0) { - const char *marker = reentrance_in_outer_callback ? "REENTRANCE_DETECTED" : "inner_after_outer"; - rep = RedisModule_Call(ctx, "lpush", "!cc", "reentrance_log", marker); - if (rep) RedisModule_FreeCallReply(rep); - } -} - -static int KeySpace_NotificationReentrance(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); - - const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "reentrant_", 10) != 0) return REDISMODULE_OK; - - RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationReentranceProbe, key, NULL, NULL); - return REDISMODULE_OK; -} - static int KeySpace_NotificationExpired(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ REDISMODULE_NOT_USED(type); REDISMODULE_NOT_USED(event); @@ -328,14 +269,6 @@ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) return REDISMODULE_ERR; } - if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationBatched) != REDISMODULE_OK){ - return REDISMODULE_ERR; - } - - if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationReentrance) != REDISMODULE_OK){ - return REDISMODULE_ERR; - } - if (with_key_events) { if(RedisModule_SubscribeToServerEvent(ctx, RedisModuleEvent_Key, KeySpace_ServerEventCallback) != REDISMODULE_OK){ return REDISMODULE_ERR; diff --git a/tests/modules/postnotifications_perkey.c b/tests/modules/postnotifications_perkey.c new file mode 100644 index 000000000..57eb9f17f --- /dev/null +++ b/tests/modules/postnotifications_perkey.c @@ -0,0 +1,113 @@ +/* This module is used to test the per-key post-notification jobs API + * (RedisModule_AddPostNotificationJobForKey). + * + * Unlike the single-shot post-notification jobs (covered by postnotifications.c), + * per-key callbacks fire at the tail of every call() — including each sub-command + * inside MULTI/EXEC — and registrations are restricted to commands that touch + * exactly one key. This module focuses on those behaviors only. + * + * ----------------------------------------------------------------------------- + * + * Copyright (c) 2020-Present, Redis Ltd. + * All rights reserved. + * + * Licensed under your choice of (a) the Redis Source Available License 2.0 + * (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the + * GNU Affero General Public License v3 (AGPLv3). + */ + +#include "redismodule.h" +#include + +/* ---------------------------------------------------------------------------- + * "batched_" path: each keyed callback appends the touched key to a sink list. + * Used to assert per-sub-command firing inside MULTI/EXEC and to assert the + * single-key guard on multi-key commands. + * ------------------------------------------------------------------------- */ + +static void KeySpace_PostNotificationBatchedKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "batched_keys", key); + if (rep) RedisModule_FreeCallReply(rep); +} + +static int KeySpace_NotificationBatched(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "batched_", 8) != 0) return REDISMODULE_OK; + if (strcmp(key_str, "batched_keys") == 0) return REDISMODULE_OK; /* skip our sink list */ + + RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationBatchedKey, key, NULL, NULL); + return REDISMODULE_OK; +} + +/* ---------------------------------------------------------------------------- + * "reentrant_" path: probes that the firing function does not re-enter while a + * nested RM_Call is in flight. The outer branch raises a marker, issues a + * nested SET (which registers a second keyed job via KSN), then lowers the + * marker. If re-entrance happened, the inner branch would observe marker==1 + * and log REENTRANCE_DETECTED. With the guard, the inner job is picked up by + * the outer drain only after the outer callback has returned. + * ------------------------------------------------------------------------- */ + +static int reentrance_in_outer_callback = 0; + +static void KeySpace_PostNotificationReentranceProbe(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + const char *key_str = RedisModule_StringPtrLen(key, NULL); + RedisModuleCallReply *rep; + + if (strcmp(key_str, "reentrant_outer") == 0) { + reentrance_in_outer_callback = 1; + rep = RedisModule_Call(ctx, "set", "!cc", "reentrant_inner", "1"); + if (rep) RedisModule_FreeCallReply(rep); + reentrance_in_outer_callback = 0; + rep = RedisModule_Call(ctx, "lpush", "!cc", "reentrance_log", "outer_done"); + if (rep) RedisModule_FreeCallReply(rep); + } else if (strcmp(key_str, "reentrant_inner") == 0) { + const char *marker = reentrance_in_outer_callback ? "REENTRANCE_DETECTED" : "inner_after_outer"; + rep = RedisModule_Call(ctx, "lpush", "!cc", "reentrance_log", marker); + if (rep) RedisModule_FreeCallReply(rep); + } +} + +static int KeySpace_NotificationReentrance(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "reentrant_", 10) != 0) return REDISMODULE_OK; + + RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationReentranceProbe, key, NULL, NULL); + return REDISMODULE_OK; +} + +int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { + REDISMODULE_NOT_USED(argv); + REDISMODULE_NOT_USED(argc); + + if (RedisModule_Init(ctx, "postnotifications_perkey", 1, REDISMODULE_APIVER_1) == REDISMODULE_ERR) { + return REDISMODULE_ERR; + } + + if (!(RedisModule_GetModuleOptionsAll() & REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS)) { + return REDISMODULE_ERR; + } + RedisModule_SetModuleOptions(ctx, REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS); + + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationBatched) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationReentrance) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + + return REDISMODULE_OK; +} + +int RedisModule_OnUnload(RedisModuleCtx *ctx) { + REDISMODULE_NOT_USED(ctx); + return REDISMODULE_OK; +} diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index a47bb315d..31a466941 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -148,69 +148,6 @@ tags "modules external:skip" { close_replication_stream $repl } - test {Test per-key post notification job fires between MULTI/EXEC sub-commands} { - r flushall - set repl [attach_to_replication_stream] - - r multi - r set batched_a 1 - r set batched_b 2 - r set batched_c 3 - r exec - - # Each SET's keyed callback fires at the tail of its own sub-command, - # before the next sub-command runs, so LPUSHes are interleaved with - # the SETs inside the MULTI/EXEC propagation block. - assert_equal {batched_c batched_b batched_a} [r lrange batched_keys 0 -1] - - assert_replication_stream $repl { - {multi} - {select *} - {set batched_a 1} - {lpush batched_keys batched_a} - {set batched_b 2} - {lpush batched_keys batched_b} - {set batched_c 3} - {lpush batched_keys batched_c} - {exec} - } - close_replication_stream $repl - } - - test {Per-key callback does not re-enter firing while a nested RM_Call is in flight} { - r flushall - - # SET reentrant_outer triggers a keyed-job registration. - # That callback sets a marker and issues an internal SET on - # reentrant_inner, which registers another keyed job. If the - # firing function re-entered while still inside the outer - # callback, the inner branch would observe the marker set and - # log REENTRANCE_DETECTED. With the guard, the inner job is - # picked up by the outer drain only after the outer callback - # has returned and the marker has been cleared. - r set reentrant_outer 1 - - set log [r lrange reentrance_log 0 -1] - assert_equal -1 [lsearch $log "REENTRANCE_DETECTED"] - assert_equal {inner_after_outer outer_done} $log - } - - test {Per-key post notification job is refused on multi-key commands} { - r flushall - set repl [attach_to_replication_stream] - - # MSET touches multiple keys; AddPostNotificationJobForKey must refuse - # the registration from KSN, so no LPUSH side-effect is propagated. - r mset batched_a 1 batched_b 2 batched_c 3 - assert_equal {} [r lrange batched_keys 0 -1] - - assert_replication_stream $repl { - {select *} - {mset batched_a 1 batched_b 2 batched_c 3} - } - close_replication_stream $repl - } - test {Test eviction} { r flushall set repl [attach_to_replication_stream] diff --git a/tests/unit/moduleapi/postnotifications_perkey.tcl b/tests/unit/moduleapi/postnotifications_perkey.tcl new file mode 100644 index 000000000..965a7ea74 --- /dev/null +++ b/tests/unit/moduleapi/postnotifications_perkey.tcl @@ -0,0 +1,70 @@ +set testmodule [file normalize tests/modules/postnotifications_perkey.so] + +tags "modules external:skip" { + start_server {} { + r module load $testmodule + + test {Test per-key post notification job fires between MULTI/EXEC sub-commands} { + r flushall + set repl [attach_to_replication_stream] + + r multi + r set batched_a 1 + r set batched_b 2 + r set batched_c 3 + r exec + + # Each SET's keyed callback fires at the tail of its own sub-command, + # before the next sub-command runs, so LPUSHes are interleaved with + # the SETs inside the MULTI/EXEC propagation block. + assert_equal {batched_c batched_b batched_a} [r lrange batched_keys 0 -1] + + assert_replication_stream $repl { + {multi} + {select *} + {set batched_a 1} + {lpush batched_keys batched_a} + {set batched_b 2} + {lpush batched_keys batched_b} + {set batched_c 3} + {lpush batched_keys batched_c} + {exec} + } + close_replication_stream $repl + } + + test {Per-key callback does not re-enter firing while a nested RM_Call is in flight} { + r flushall + + # SET reentrant_outer triggers a keyed-job registration. + # That callback sets a marker and issues an internal SET on + # reentrant_inner, which registers another keyed job. If the + # firing function re-entered while still inside the outer + # callback, the inner branch would observe the marker set and + # log REENTRANCE_DETECTED. With the guard, the inner job is + # picked up by the outer drain only after the outer callback + # has returned and the marker has been cleared. + r set reentrant_outer 1 + + set log [r lrange reentrance_log 0 -1] + assert_equal -1 [lsearch $log "REENTRANCE_DETECTED"] + assert_equal {inner_after_outer outer_done} $log + } + + test {Per-key post notification job is refused on multi-key commands} { + r flushall + set repl [attach_to_replication_stream] + + # MSET touches multiple keys; AddPostNotificationJobForKey must refuse + # the registration from KSN, so no LPUSH side-effect is propagated. + r mset batched_a 1 batched_b 2 batched_c 3 + assert_equal {} [r lrange batched_keys 0 -1] + + assert_replication_stream $repl { + {select *} + {mset batched_a 1 batched_b 2 batched_c 3} + } + close_replication_stream $repl + } + } +} From 47ebbe90de046eac3e68764ffa7ab1e063061a65 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Thu, 21 May 2026 19:12:09 +0200 Subject: [PATCH 07/24] add set of tests in pair with regular post notification jobs --- tests/modules/postnotifications_perkey.c | 88 ++++++++++++++ .../moduleapi/postnotifications_perkey.tcl | 114 ++++++++++++++++++ 2 files changed, 202 insertions(+) diff --git a/tests/modules/postnotifications_perkey.c b/tests/modules/postnotifications_perkey.c index 57eb9f17f..2a73bd168 100644 --- a/tests/modules/postnotifications_perkey.c +++ b/tests/modules/postnotifications_perkey.c @@ -43,6 +43,85 @@ static int KeySpace_NotificationBatched(RedisModuleCtx *ctx, int type, const cha return REDISMODULE_OK; } +/* ---------------------------------------------------------------------------- + * "hash_" path: HSET/HEXPIRE both fire NOTIFY_HASH against a single hash key, + * so they pass the single-key guard. The callback LPUSHes the touched key to + * a sink list, letting us assert that the per-key callback fires between + * successive HSET/HEXPIRE sub-commands on the same hash inside MULTI/EXEC + * (the original motivation for this API — RED-197766). + * ------------------------------------------------------------------------- */ + +static void KeySpace_PostNotificationHashKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "hash_keys", key); + if (rep) RedisModule_FreeCallReply(rep); +} + +static int KeySpace_NotificationHash(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "hash_", 5) != 0) return REDISMODULE_OK; + + RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationHashKey, key, NULL, NULL); + return REDISMODULE_OK; +} + +/* ---------------------------------------------------------------------------- + * "expire_" path: NOTIFY_EXPIRED fires on the lazy-DEL path with + * server.executing_client still pointing at the command that touched the key, + * so the single-key guard accepts the registration. The callback LPUSHes the + * expired key name to a sink list, letting us assert that lazy expire drives + * a per-key job. Combined with the "read_" path below, this also exercises + * lazy expire triggered from inside an outer per-key callback's RM_Call. + * ------------------------------------------------------------------------- */ + +static void KeySpace_PostNotificationExpiredKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "expired_keys", key); + if (rep) RedisModule_FreeCallReply(rep); +} + +static int KeySpace_NotificationExpired(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "expire_", 7) != 0) return REDISMODULE_OK; + + RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationExpiredKey, key, NULL, NULL); + return REDISMODULE_OK; +} + +/* ---------------------------------------------------------------------------- + * "read_" path: the outer per-key callback for a "read_" key issues a + * GET on . If is TTL-expired, that GET triggers a lazy DEL, + * which fires NOTIFY_EXPIRED and registers a second per-key job from inside + * the outer callback. The second job must fire from the outer drain (not + * nested inside the outer callback's stack) — the reentrance guard combined + * with the per-call() firing hook is what makes that work. + * ------------------------------------------------------------------------- */ + +static void KeySpace_PostNotificationReadKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + const char *key_str = RedisModule_StringPtrLen(key, NULL); + const char *target = key_str + 5; /* strip "read_" */ + RedisModuleCallReply *rep = RedisModule_Call(ctx, "get", "!c", target); + if (rep) RedisModule_FreeCallReply(rep); +} + +static int KeySpace_NotificationRead(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "read_", 5) != 0) return REDISMODULE_OK; + + RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationReadKey, key, NULL, NULL); + return REDISMODULE_OK; +} + /* ---------------------------------------------------------------------------- * "reentrant_" path: probes that the firing function does not re-enter while a * nested RM_Call is in flight. The outer branch raises a marker, issues a @@ -100,6 +179,15 @@ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationBatched) != REDISMODULE_OK) { return REDISMODULE_ERR; } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_HASH, KeySpace_NotificationHash) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_EXPIRED, KeySpace_NotificationExpired) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationRead) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationReentrance) != REDISMODULE_OK) { return REDISMODULE_ERR; } diff --git a/tests/unit/moduleapi/postnotifications_perkey.tcl b/tests/unit/moduleapi/postnotifications_perkey.tcl index 965a7ea74..2a65862e0 100644 --- a/tests/unit/moduleapi/postnotifications_perkey.tcl +++ b/tests/unit/moduleapi/postnotifications_perkey.tcl @@ -4,6 +4,27 @@ tags "modules external:skip" { start_server {} { r module load $testmodule + test {Per-key post notification job fires on a single command outside MULTI} { + r flushall + set repl [attach_to_replication_stream] + + # The simplest firing path: one SET outside any MULTI/EXEC. The + # per-key callback fires at the tail of SET's call() and its + # LPUSH side-effect propagates wrapped with the SET in one + # implicit MULTI/EXEC. + r set batched_a 1 + assert_equal {batched_a} [r lrange batched_keys 0 -1] + + assert_replication_stream $repl { + {multi} + {select *} + {set batched_a 1} + {lpush batched_keys batched_a} + {exec} + } + close_replication_stream $repl + } + test {Test per-key post notification job fires between MULTI/EXEC sub-commands} { r flushall set repl [attach_to_replication_stream] @@ -66,5 +87,98 @@ tags "modules external:skip" { } close_replication_stream $repl } + + test {Per-key post notification job fires between HSET and HEXPIRE on the same hash inside MULTI/EXEC} { + r flushall + set repl [attach_to_replication_stream] + + # HSET and HEXPIRE both touch exactly one key, so they pass the + # single-key guard. The per-key callback fires at the tail of each + # sub-command's call(), interleaving an LPUSH side-effect between + # successive HSET/HEXPIRE on the same hash. This is the original + # motivation for the per-key API (RED-197766). + r multi + r hset hash_h f1 v1 + r hset hash_h f2 v2 + r hexpire hash_h 100 FIELDS 1 f1 + r exec + + assert_equal {hash_h hash_h hash_h} [r lrange hash_keys 0 -1] + + assert_replication_stream $repl { + {multi} + {select *} + {hset hash_h f1 v1} + {lpush hash_keys hash_h} + {hset hash_h f2 v2} + {lpush hash_keys hash_h} + {hpexpireat hash_h * FIELDS 1 f1} + {lpush hash_keys hash_h} + {exec} + } + close_replication_stream $repl + } + + test {Lazy expire registers a per-key post notification job} { + r flushall + r DEBUG SET-ACTIVE-EXPIRE 0 + set repl [attach_to_replication_stream] + + # GET on an expired key triggers a lazy DEL inside GET's call(). + # The DEL fires NOTIFY_EXPIRED with server.executing_client still + # set to the GET, so the single-key guard accepts the registration + # and the per-key callback LPUSHes the expired key to the sink. + r set expire_x 1 + r pexpire expire_x 1 + after 10 + assert_equal {} [r get expire_x] + + assert_equal {expire_x} [r lrange expired_keys 0 -1] + + assert_replication_stream $repl { + {select *} + {set expire_x 1} + {pexpireat expire_x *} + {multi} + {del expire_x} + {lpush expired_keys expire_x} + {exec} + } + close_replication_stream $repl + r DEBUG SET-ACTIVE-EXPIRE 1 + } {OK} {needs:debug} + + test {Lazy expire from inside an outer per-key callback registers a second per-key job} { + r flushall + r DEBUG SET-ACTIVE-EXPIRE 0 + set repl [attach_to_replication_stream] + + # Combined coverage of the reentrance guard and lazy-expire-driven + # KSN: SET read_expire_target's per-key callback issues GET + # expire_target, whose lazy DEL fires NOTIFY_EXPIRED and registers + # a second per-key job from inside the outer callback. That job + # must fire from the outer drain (not nested inside the outer + # callback's stack), which is what the reentrance guard combined + # with the per-call() firing hook makes possible. + r set expire_target 1 + r pexpire expire_target 1 + after 10 + r set read_expire_target 1 + + assert_equal {expire_target} [r lrange expired_keys 0 -1] + + assert_replication_stream $repl { + {select *} + {set expire_target 1} + {pexpireat expire_target *} + {multi} + {set read_expire_target 1} + {del expire_target} + {lpush expired_keys expire_target} + {exec} + } + close_replication_stream $repl + r DEBUG SET-ACTIVE-EXPIRE 1 + } {OK} {needs:debug} } } From dce69e5f45d8321eaad8b81010516caf4b5a20d9 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Fri, 22 May 2026 12:17:33 +0200 Subject: [PATCH 08/24] refactor testing to show shared behavior --- runtest-moduleapi | 1 - tests/modules/Makefile | 1 - tests/modules/postnotifications.c | 163 ++++++++- tests/modules/postnotifications_perkey.c | 201 ------------ tests/unit/moduleapi/postnotifications.tcl | 308 ++++++++++++++---- .../moduleapi/postnotifications_perkey.tcl | 184 ----------- 6 files changed, 402 insertions(+), 456 deletions(-) delete mode 100644 tests/modules/postnotifications_perkey.c delete mode 100644 tests/unit/moduleapi/postnotifications_perkey.tcl diff --git a/runtest-moduleapi b/runtest-moduleapi index bcb53c653..2e4109390 100755 --- a/runtest-moduleapi +++ b/runtest-moduleapi @@ -58,7 +58,6 @@ $TCLSH tests/test_helper.tcl \ --single unit/moduleapi/publish \ --single unit/moduleapi/usercall \ --single unit/moduleapi/postnotifications \ ---single unit/moduleapi/postnotifications_perkey \ --single unit/moduleapi/async_rm_call \ --single unit/moduleapi/moduleauth \ --single unit/moduleapi/rdbloadsave \ diff --git a/tests/modules/Makefile b/tests/modules/Makefile index 35be8fb64..0659497cf 100644 --- a/tests/modules/Makefile +++ b/tests/modules/Makefile @@ -79,7 +79,6 @@ TEST_MODULES = \ publish.so \ usercall.so \ postnotifications.so \ - postnotifications_perkey.so \ moduleauthtwo.so \ rdbloadsave.so \ crash.so \ diff --git a/tests/modules/postnotifications.c b/tests/modules/postnotifications.c index 96fb85918..2a9b721c7 100644 --- a/tests/modules/postnotifications.c +++ b/tests/modules/postnotifications.c @@ -52,7 +52,14 @@ static void KeySpace_PostNotificationString(RedisModuleCtx *ctx, void *pd) { static int KeySpace_NotificationExpired(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ REDISMODULE_NOT_USED(type); REDISMODULE_NOT_USED(event); - REDISMODULE_NOT_USED(key); + + /* Per-key fixtures own the "expire_" prefix; let their dedicated handler + * react to lazy expire on those keys without this counter-style handler + * interfering. The parametrized "Test lazy expire" iterates over the + * legacy regular key ("x") and the per-key key ("expire_x") to assert + * parity across both APIs. */ + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "expire_", 7) == 0) return REDISMODULE_OK; RedisModuleString *new_key = RedisModule_CreateString(NULL, "expired", 7); int res = RedisModule_AddPostNotificationJob(ctx, KeySpace_PostNotificationString, new_key, KeySpace_PostNotificationStringFreePD); @@ -225,6 +232,143 @@ static void KeySpace_ServerEventCallback(RedisModuleCtx *ctx, RedisModuleEvent e if (res == REDISMODULE_ERR) KeySpace_ServerEventPostNotificationFree(pn_ctx); } +/* ============================================================================ + * Per-key post-notification fixtures (RedisModule_AddPostNotificationJobForKey). + * + * Per-key callbacks fire at the tail of every call() — including each + * sub-command inside MULTI/EXEC — and registrations are restricted to commands + * that touch exactly one key. The fixtures below mirror the regular-API + * fixtures above, but exercise the per-key API surface, so a single .so can + * cover both APIs in one test file. + * ========================================================================= */ + +/* "batched_" path: each keyed callback appends the touched key to a sink list. + * Used to assert per-sub-command firing inside MULTI/EXEC and to assert the + * single-key guard on multi-key commands. */ +static void KeySpace_PostNotificationBatchedKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "batched_keys", key); + if (rep) RedisModule_FreeCallReply(rep); +} + +static int KeySpace_NotificationBatched(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "batched_", 8) != 0) return REDISMODULE_OK; + if (strcmp(key_str, "batched_keys") == 0) return REDISMODULE_OK; /* skip our sink list */ + + RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationBatchedKey, key, NULL, NULL); + return REDISMODULE_OK; +} + +/* "hash_" path: HSET/HEXPIRE both fire NOTIFY_HASH against a single hash key, + * so they pass the single-key guard. Used to assert that the per-key callback + * fires between successive HSET/HEXPIRE sub-commands on the same hash inside + * MULTI/EXEC (the original motivation for this API — RED-197766). */ +static void KeySpace_PostNotificationHashKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "hash_keys", key); + if (rep) RedisModule_FreeCallReply(rep); +} + +static int KeySpace_NotificationHash(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "hash_", 5) != 0) return REDISMODULE_OK; + + RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationHashKey, key, NULL, NULL); + return REDISMODULE_OK; +} + +/* "expire_" path: NOTIFY_EXPIRED fires on the lazy-DEL path with + * server.executing_client still pointing at the accessing command, so the + * single-key guard accepts the registration. The callback LPUSHes the expired + * key name to a sink list. Paired with the legacy regular fixture (key "x", + * INCR-counter shape) for the parametrized "Test lazy expire" test. */ +static void KeySpace_PostNotificationExpiredPerKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "expired_keys", key); + if (rep) RedisModule_FreeCallReply(rep); +} + +static int KeySpace_NotificationExpiredPerKey(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "expire_", 7) != 0) return REDISMODULE_OK; + + RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationExpiredPerKey, key, NULL, NULL); + return REDISMODULE_OK; +} + +/* "pkread_" path: the outer per-key callback for a "pkread_" key issues + * a GET on . If is TTL-expired, that GET triggers a lazy DEL, + * which fires NOTIFY_EXPIRED and registers a second per-key job from inside + * the outer callback. The second job must fire from the outer drain (not + * nested inside the outer callback's stack) — the reentrance guard combined + * with the per-call() firing hook is what makes that work. Distinct from the + * regular module's "read_" prefix to keep their drains independent. */ +static void KeySpace_PostNotificationPerKeyRead(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + const char *key_str = RedisModule_StringPtrLen(key, NULL); + const char *target = key_str + 7; /* strip "pkread_" */ + RedisModuleCallReply *rep = RedisModule_Call(ctx, "get", "!c", target); + if (rep) RedisModule_FreeCallReply(rep); +} + +static int KeySpace_NotificationPerKeyRead(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "pkread_", 7) != 0) return REDISMODULE_OK; + + RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationPerKeyRead, key, NULL, NULL); + return REDISMODULE_OK; +} + +/* "reentrant_" path: probes that the firing function does not re-enter while a + * nested RM_Call is in flight. The outer branch raises a marker, issues a + * nested SET (which registers a second keyed job via KSN), then lowers the + * marker. If re-entrance happened, the inner branch would observe marker==1 + * and log REENTRANCE_DETECTED. */ +static int reentrance_in_outer_callback = 0; + +static void KeySpace_PostNotificationReentranceProbe(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + const char *key_str = RedisModule_StringPtrLen(key, NULL); + RedisModuleCallReply *rep; + + if (strcmp(key_str, "reentrant_outer") == 0) { + reentrance_in_outer_callback = 1; + rep = RedisModule_Call(ctx, "set", "!cc", "reentrant_inner", "1"); + if (rep) RedisModule_FreeCallReply(rep); + reentrance_in_outer_callback = 0; + rep = RedisModule_Call(ctx, "lpush", "!cc", "reentrance_log", "outer_done"); + if (rep) RedisModule_FreeCallReply(rep); + } else if (strcmp(key_str, "reentrant_inner") == 0) { + const char *marker = reentrance_in_outer_callback ? "REENTRANCE_DETECTED" : "inner_after_outer"; + rep = RedisModule_Call(ctx, "lpush", "!cc", "reentrance_log", marker); + if (rep) RedisModule_FreeCallReply(rep); + } +} + +static int KeySpace_NotificationReentrance(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "reentrant_", 10) != 0) return REDISMODULE_OK; + + RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationReentranceProbe, key, NULL, NULL); + return REDISMODULE_OK; +} + /* This function must be present on each Redis module. It is used in order to * register the commands into the Redis server. */ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { @@ -275,6 +419,23 @@ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) } } + /* Per-key API subscriptions (and the "r_expire_" parity twin). */ + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationBatched) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_HASH, KeySpace_NotificationHash) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_EXPIRED, KeySpace_NotificationExpiredPerKey) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationPerKeyRead) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationReentrance) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_CreateCommand(ctx, "postnotification.async_set", KeySpace_PostNotificationsAsyncSet, "write", 0, 0, 0) == REDISMODULE_ERR){ return REDISMODULE_ERR; diff --git a/tests/modules/postnotifications_perkey.c b/tests/modules/postnotifications_perkey.c deleted file mode 100644 index 2a73bd168..000000000 --- a/tests/modules/postnotifications_perkey.c +++ /dev/null @@ -1,201 +0,0 @@ -/* This module is used to test the per-key post-notification jobs API - * (RedisModule_AddPostNotificationJobForKey). - * - * Unlike the single-shot post-notification jobs (covered by postnotifications.c), - * per-key callbacks fire at the tail of every call() — including each sub-command - * inside MULTI/EXEC — and registrations are restricted to commands that touch - * exactly one key. This module focuses on those behaviors only. - * - * ----------------------------------------------------------------------------- - * - * Copyright (c) 2020-Present, Redis Ltd. - * All rights reserved. - * - * Licensed under your choice of (a) the Redis Source Available License 2.0 - * (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the - * GNU Affero General Public License v3 (AGPLv3). - */ - -#include "redismodule.h" -#include - -/* ---------------------------------------------------------------------------- - * "batched_" path: each keyed callback appends the touched key to a sink list. - * Used to assert per-sub-command firing inside MULTI/EXEC and to assert the - * single-key guard on multi-key commands. - * ------------------------------------------------------------------------- */ - -static void KeySpace_PostNotificationBatchedKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { - REDISMODULE_NOT_USED(pd); - RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "batched_keys", key); - if (rep) RedisModule_FreeCallReply(rep); -} - -static int KeySpace_NotificationBatched(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); - - const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "batched_", 8) != 0) return REDISMODULE_OK; - if (strcmp(key_str, "batched_keys") == 0) return REDISMODULE_OK; /* skip our sink list */ - - RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationBatchedKey, key, NULL, NULL); - return REDISMODULE_OK; -} - -/* ---------------------------------------------------------------------------- - * "hash_" path: HSET/HEXPIRE both fire NOTIFY_HASH against a single hash key, - * so they pass the single-key guard. The callback LPUSHes the touched key to - * a sink list, letting us assert that the per-key callback fires between - * successive HSET/HEXPIRE sub-commands on the same hash inside MULTI/EXEC - * (the original motivation for this API — RED-197766). - * ------------------------------------------------------------------------- */ - -static void KeySpace_PostNotificationHashKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { - REDISMODULE_NOT_USED(pd); - RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "hash_keys", key); - if (rep) RedisModule_FreeCallReply(rep); -} - -static int KeySpace_NotificationHash(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); - - const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "hash_", 5) != 0) return REDISMODULE_OK; - - RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationHashKey, key, NULL, NULL); - return REDISMODULE_OK; -} - -/* ---------------------------------------------------------------------------- - * "expire_" path: NOTIFY_EXPIRED fires on the lazy-DEL path with - * server.executing_client still pointing at the command that touched the key, - * so the single-key guard accepts the registration. The callback LPUSHes the - * expired key name to a sink list, letting us assert that lazy expire drives - * a per-key job. Combined with the "read_" path below, this also exercises - * lazy expire triggered from inside an outer per-key callback's RM_Call. - * ------------------------------------------------------------------------- */ - -static void KeySpace_PostNotificationExpiredKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { - REDISMODULE_NOT_USED(pd); - RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "expired_keys", key); - if (rep) RedisModule_FreeCallReply(rep); -} - -static int KeySpace_NotificationExpired(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); - - const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "expire_", 7) != 0) return REDISMODULE_OK; - - RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationExpiredKey, key, NULL, NULL); - return REDISMODULE_OK; -} - -/* ---------------------------------------------------------------------------- - * "read_" path: the outer per-key callback for a "read_" key issues a - * GET on . If is TTL-expired, that GET triggers a lazy DEL, - * which fires NOTIFY_EXPIRED and registers a second per-key job from inside - * the outer callback. The second job must fire from the outer drain (not - * nested inside the outer callback's stack) — the reentrance guard combined - * with the per-call() firing hook is what makes that work. - * ------------------------------------------------------------------------- */ - -static void KeySpace_PostNotificationReadKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { - REDISMODULE_NOT_USED(pd); - const char *key_str = RedisModule_StringPtrLen(key, NULL); - const char *target = key_str + 5; /* strip "read_" */ - RedisModuleCallReply *rep = RedisModule_Call(ctx, "get", "!c", target); - if (rep) RedisModule_FreeCallReply(rep); -} - -static int KeySpace_NotificationRead(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); - - const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "read_", 5) != 0) return REDISMODULE_OK; - - RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationReadKey, key, NULL, NULL); - return REDISMODULE_OK; -} - -/* ---------------------------------------------------------------------------- - * "reentrant_" path: probes that the firing function does not re-enter while a - * nested RM_Call is in flight. The outer branch raises a marker, issues a - * nested SET (which registers a second keyed job via KSN), then lowers the - * marker. If re-entrance happened, the inner branch would observe marker==1 - * and log REENTRANCE_DETECTED. With the guard, the inner job is picked up by - * the outer drain only after the outer callback has returned. - * ------------------------------------------------------------------------- */ - -static int reentrance_in_outer_callback = 0; - -static void KeySpace_PostNotificationReentranceProbe(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { - REDISMODULE_NOT_USED(pd); - const char *key_str = RedisModule_StringPtrLen(key, NULL); - RedisModuleCallReply *rep; - - if (strcmp(key_str, "reentrant_outer") == 0) { - reentrance_in_outer_callback = 1; - rep = RedisModule_Call(ctx, "set", "!cc", "reentrant_inner", "1"); - if (rep) RedisModule_FreeCallReply(rep); - reentrance_in_outer_callback = 0; - rep = RedisModule_Call(ctx, "lpush", "!cc", "reentrance_log", "outer_done"); - if (rep) RedisModule_FreeCallReply(rep); - } else if (strcmp(key_str, "reentrant_inner") == 0) { - const char *marker = reentrance_in_outer_callback ? "REENTRANCE_DETECTED" : "inner_after_outer"; - rep = RedisModule_Call(ctx, "lpush", "!cc", "reentrance_log", marker); - if (rep) RedisModule_FreeCallReply(rep); - } -} - -static int KeySpace_NotificationReentrance(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); - - const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "reentrant_", 10) != 0) return REDISMODULE_OK; - - RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationReentranceProbe, key, NULL, NULL); - return REDISMODULE_OK; -} - -int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { - REDISMODULE_NOT_USED(argv); - REDISMODULE_NOT_USED(argc); - - if (RedisModule_Init(ctx, "postnotifications_perkey", 1, REDISMODULE_APIVER_1) == REDISMODULE_ERR) { - return REDISMODULE_ERR; - } - - if (!(RedisModule_GetModuleOptionsAll() & REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS)) { - return REDISMODULE_ERR; - } - RedisModule_SetModuleOptions(ctx, REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS); - - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationBatched) != REDISMODULE_OK) { - return REDISMODULE_ERR; - } - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_HASH, KeySpace_NotificationHash) != REDISMODULE_OK) { - return REDISMODULE_ERR; - } - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_EXPIRED, KeySpace_NotificationExpired) != REDISMODULE_OK) { - return REDISMODULE_ERR; - } - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationRead) != REDISMODULE_OK) { - return REDISMODULE_ERR; - } - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationReentrance) != REDISMODULE_OK) { - return REDISMODULE_ERR; - } - - return REDISMODULE_OK; -} - -int RedisModule_OnUnload(RedisModuleCtx *ctx) { - REDISMODULE_NOT_USED(ctx); - return REDISMODULE_OK; -} diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index 31a466941..f314554ad 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -1,38 +1,61 @@ set testmodule [file normalize tests/modules/postnotifications.so] +# ---------------------------------------------------------------------------- +# Both post-notification APIs (RM_AddPostNotificationJob and +# RM_AddPostNotificationJobForKey) share the same .so. Tests that exercise +# behavior identical across both APIs — i.e. anything that doesn't depend on +# the per-key API's distinct firing point inside MULTI/EXEC — are written as a +# `foreach` over (regular, perkey) so the same assertions hold for both. +# API-specific tests stay as standalone tests. +# ---------------------------------------------------------------------------- + tags "modules external:skip" { start_server {} { r module load $testmodule with_key_events - test {Test write on post notification callback} { - set repl [attach_to_replication_stream] + # Common: a single SET fires the post-notification job registered by + # the KSN handler, and the side effect is propagated wrapped with the + # SET in one implicit MULTI/EXEC. The second SET overwrites the key, + # which also fires the RedisModuleEvent_Key.before_overwritten server + # event (registered first during dbGenericDelete inside setKey, before + # notifyKeyspaceEvent). + # + # In the regular iteration both side effects ride the regular drain + # (server-event LPUSH first, KSN INCRs second). In the per-key + # iteration the per-key drain runs first in afterCommand, so the + # per-key LPUSH appears before the server-event LPUSH. + foreach {api setup_key drain_ops} { + regular string_x {{incr string_changed{string_x}} {incr string_total}} + perkey batched_a {{lpush batched_keys batched_a}} + } { + test "Post-notification job fires on writes ($api API)" { + r flushall + set repl [attach_to_replication_stream] - r set string_x 1 - assert_equal {1} [r get string_changed{string_x}] - assert_equal {1} [r get string_total] + r set $setup_key 1 + r set $setup_key 2 - r set string_x 2 - assert_equal {2} [r get string_changed{string_x}] - assert_equal {2} [r get string_total] + set expected [list {multi} {select *} [list set $setup_key 1]] + foreach op $drain_ops { lappend expected $op } + lappend expected {exec} - # the {lpush before_overwritten string_x} is a post notification job registered when 'string_x' was overwritten - assert_replication_stream $repl { - {multi} - {select *} - {set string_x 1} - {incr string_changed{string_x}} - {incr string_total} - {exec} - {multi} - {set string_x 2} - {lpush before_overwritten string_x} - {incr string_changed{string_x}} - {incr string_total} - {exec} + lappend expected {multi} [list set $setup_key 2] + if {$api eq "regular"} { + lappend expected [list lpush before_overwritten $setup_key] + foreach op $drain_ops { lappend expected $op } + } else { + foreach op $drain_ops { lappend expected $op } + lappend expected [list lpush before_overwritten $setup_key] + } + lappend expected {exec} + + assert_replication_stream $repl $expected + close_replication_stream $repl } - close_replication_stream $repl } + # Regular-only: the per-key API can't fire from a thread because it + # requires server.executing_client to be set (single-key guard). test {Test write on post notification callback from module thread} { r flushall set repl [attach_to_replication_stream] @@ -52,6 +75,8 @@ tags "modules external:skip" { close_replication_stream $repl } + # Regular-only: active expire fires from cron with no executing_client, + # so the per-key API's single-key guard refuses the registration. test {Test active expire} { r flushall set repl [attach_to_replication_stream] @@ -80,57 +105,109 @@ tags "modules external:skip" { close_replication_stream $repl } - test {Test lazy expire} { - r flushall - r DEBUG SET-ACTIVE-EXPIRE 0 - set repl [attach_to_replication_stream] + # Common: lazy DEL on key access fires NOTIFY_EXPIRED with + # server.executing_client still set, so a post-notification job + # registered from inside the handler is queued and propagated as part + # of the same execution unit. Iterates over the legacy regular fixture + # (key "x", INCR-counter sink) and the per-key fixture (key + # "expire_x", LPUSH-list sink). + foreach {api key drain_op} { + regular x {incr expired} + perkey expire_x {lpush expired_keys expire_x} + } { + test "Test lazy expire ($api API)" { + r flushall + r DEBUG SET-ACTIVE-EXPIRE 0 + set repl [attach_to_replication_stream] - r set x 1 - r pexpire x 1 - after 10 - assert_equal {} [r get x] + r set $key 1 + r pexpire $key 1 + after 10 + assert_equal {} [r get $key] - # the {lpush before_expired x} is a post notification job registered before 'x' got expired - assert_replication_stream $repl { - {select *} - {set x 1} - {pexpireat x *} - {multi} - {del x} - {lpush before_expired x} - {incr expired} - {exec} - } - close_replication_stream $repl - r DEBUG SET-ACTIVE-EXPIRE 1 - } {OK} {needs:debug} + # before_expired comes from the RedisModuleEvent_Key server event, + # registered during dbGenericDelete BEFORE notifyKeyspaceEvent. + # Regular iteration: both side effects ride the regular drain; + # server-event LPUSH appears before the KSN-driven INCR. Per-key + # iteration: per-key drain runs first in afterCommand, so the + # per-key LPUSH appears before the server-event LPUSH. + if {$api eq "regular"} { + assert_replication_stream $repl [list \ + {select *} \ + [list set $key 1] \ + [list pexpireat $key *] \ + {multi} \ + [list del $key] \ + [list lpush before_expired $key] \ + $drain_op \ + {exec}] + } else { + assert_replication_stream $repl [list \ + {select *} \ + [list set $key 1] \ + [list pexpireat $key *] \ + {multi} \ + [list del $key] \ + $drain_op \ + [list lpush before_expired $key] \ + {exec}] + } + close_replication_stream $repl + r DEBUG SET-ACTIVE-EXPIRE 1 + } {OK} {needs:debug} + } - test {Test lazy expire inside post job notification} { - r flushall - r DEBUG SET-ACTIVE-EXPIRE 0 - set repl [attach_to_replication_stream] + # Common: an outer post-notification callback's RM_Call accesses an + # already-expired sibling key; the resulting lazy DEL fires KSN, which + # registers another post-notification job from inside the outer + # callback. That inner job must fire from the outer drain, not nested + # inside the outer callback's stack. The regular API gates this via + # execution_nesting; the per-key API gates it via the dedicated + # firing_keyed_post_notif_jobs flag. + foreach {api outer_key inner_key drain_op} { + regular read_x x {incr expired} + perkey pkread_expire_target expire_target {lpush expired_keys expire_target} + } { + test "Test lazy expire inside post job notification ($api API)" { + r flushall + r DEBUG SET-ACTIVE-EXPIRE 0 + set repl [attach_to_replication_stream] - r set x 1 - r pexpire x 1 - after 10 - assert_equal {OK} [r set read_x 1] + r set $inner_key 1 + r pexpire $inner_key 1 + after 10 + assert_equal {OK} [r set $outer_key 1] - # the {lpush before_expired x} is a post notification job registered before 'x' got expired - assert_replication_stream $repl { - {select *} - {set x 1} - {pexpireat x *} - {multi} - {set read_x 1} - {del x} - {lpush before_expired x} - {incr expired} - {exec} - } - close_replication_stream $repl - r DEBUG SET-ACTIVE-EXPIRE 1 - } {OK} {needs:debug} + if {$api eq "regular"} { + assert_replication_stream $repl [list \ + {select *} \ + [list set $inner_key 1] \ + [list pexpireat $inner_key *] \ + {multi} \ + [list set $outer_key 1] \ + [list del $inner_key] \ + [list lpush before_expired $inner_key] \ + $drain_op \ + {exec}] + } else { + assert_replication_stream $repl [list \ + {select *} \ + [list set $inner_key 1] \ + [list pexpireat $inner_key *] \ + {multi} \ + [list set $outer_key 1] \ + [list del $inner_key] \ + $drain_op \ + [list lpush before_expired $inner_key] \ + {exec}] + } + close_replication_stream $repl + r DEBUG SET-ACTIVE-EXPIRE 1 + } {OK} {needs:debug} + } + # Regular-only: tests REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS, + # which is orthogonal to the post-notification API surface. test {Test nested keyspace notification} { r flushall set repl [attach_to_replication_stream] @@ -148,6 +225,101 @@ tags "modules external:skip" { close_replication_stream $repl } + # Per-key-only: the per-key callback fires at the tail of EVERY call(), + # including each sub-command inside MULTI/EXEC. The regular API only + # fires at the outermost EXEC. + test {Per-key post notification job fires between MULTI/EXEC sub-commands} { + r flushall + set repl [attach_to_replication_stream] + + r multi + r set batched_a 1 + r set batched_b 2 + r set batched_c 3 + r exec + + assert_equal {batched_c batched_b batched_a} [r lrange batched_keys 0 -1] + + assert_replication_stream $repl { + {multi} + {select *} + {set batched_a 1} + {lpush batched_keys batched_a} + {set batched_b 2} + {lpush batched_keys batched_b} + {set batched_c 3} + {lpush batched_keys batched_c} + {exec} + } + close_replication_stream $repl + } + + # Per-key-only: the firing function uses its own reentrance guard + # (firing_keyed_post_notif_jobs) because per-key callbacks must fire + # even when execution_nesting > 0. Test that a nested RM_Call inside + # an outer per-key callback does not re-enter the firing function. + test {Per-key callback does not re-enter firing while a nested RM_Call is in flight} { + r flushall + + r set reentrant_outer 1 + + set log [r lrange reentrance_log 0 -1] + assert_equal -1 [lsearch $log "REENTRANCE_DETECTED"] + assert_equal {inner_after_outer outer_done} $log + } + + # Per-key-only: the single-key guard refuses registration when the + # current command touches more than one key. The regular API has no + # such constraint. + test {Per-key post notification job is refused on multi-key commands} { + r flushall + set repl [attach_to_replication_stream] + + r mset batched_a 1 batched_b 2 batched_c 3 + assert_equal {} [r lrange batched_keys 0 -1] + + assert_replication_stream $repl { + {select *} + {mset batched_a 1 batched_b 2 batched_c 3} + } + close_replication_stream $repl + } + + # Per-key-only: HSET and HEXPIRE both touch a single hash, so they + # pass the single-key guard. The per-key callback fires at the tail of + # each sub-command's call(), interleaving an LPUSH between successive + # HSET/HEXPIRE on the same hash inside MULTI/EXEC (the original + # motivation for this API — RED-197766). + test {Per-key post notification job fires between HSET and HEXPIRE on the same hash inside MULTI/EXEC} { + r flushall + set repl [attach_to_replication_stream] + + r multi + r hset hash_h f1 v1 + r hset hash_h f2 v2 + r hexpire hash_h 100 FIELDS 1 f1 + r exec + + assert_equal {hash_h hash_h hash_h} [r lrange hash_keys 0 -1] + + assert_replication_stream $repl { + {multi} + {select *} + {hset hash_h f1 v1} + {lpush hash_keys hash_h} + {hset hash_h f2 v2} + {lpush hash_keys hash_h} + {hpexpireat hash_h * FIELDS 1 f1} + {lpush hash_keys hash_h} + {exec} + } + close_replication_stream $repl + } + + # Regular-only: eviction triggers from performEvictions with no + # executing_client for the evicted key, so the per-key API's + # single-key guard refuses the registration. Placed last in the + # section because it sets maxmemory=1 and OOMs subsequent writes. test {Test eviction} { r flushall set repl [attach_to_replication_stream] diff --git a/tests/unit/moduleapi/postnotifications_perkey.tcl b/tests/unit/moduleapi/postnotifications_perkey.tcl deleted file mode 100644 index 2a65862e0..000000000 --- a/tests/unit/moduleapi/postnotifications_perkey.tcl +++ /dev/null @@ -1,184 +0,0 @@ -set testmodule [file normalize tests/modules/postnotifications_perkey.so] - -tags "modules external:skip" { - start_server {} { - r module load $testmodule - - test {Per-key post notification job fires on a single command outside MULTI} { - r flushall - set repl [attach_to_replication_stream] - - # The simplest firing path: one SET outside any MULTI/EXEC. The - # per-key callback fires at the tail of SET's call() and its - # LPUSH side-effect propagates wrapped with the SET in one - # implicit MULTI/EXEC. - r set batched_a 1 - assert_equal {batched_a} [r lrange batched_keys 0 -1] - - assert_replication_stream $repl { - {multi} - {select *} - {set batched_a 1} - {lpush batched_keys batched_a} - {exec} - } - close_replication_stream $repl - } - - test {Test per-key post notification job fires between MULTI/EXEC sub-commands} { - r flushall - set repl [attach_to_replication_stream] - - r multi - r set batched_a 1 - r set batched_b 2 - r set batched_c 3 - r exec - - # Each SET's keyed callback fires at the tail of its own sub-command, - # before the next sub-command runs, so LPUSHes are interleaved with - # the SETs inside the MULTI/EXEC propagation block. - assert_equal {batched_c batched_b batched_a} [r lrange batched_keys 0 -1] - - assert_replication_stream $repl { - {multi} - {select *} - {set batched_a 1} - {lpush batched_keys batched_a} - {set batched_b 2} - {lpush batched_keys batched_b} - {set batched_c 3} - {lpush batched_keys batched_c} - {exec} - } - close_replication_stream $repl - } - - test {Per-key callback does not re-enter firing while a nested RM_Call is in flight} { - r flushall - - # SET reentrant_outer triggers a keyed-job registration. - # That callback sets a marker and issues an internal SET on - # reentrant_inner, which registers another keyed job. If the - # firing function re-entered while still inside the outer - # callback, the inner branch would observe the marker set and - # log REENTRANCE_DETECTED. With the guard, the inner job is - # picked up by the outer drain only after the outer callback - # has returned and the marker has been cleared. - r set reentrant_outer 1 - - set log [r lrange reentrance_log 0 -1] - assert_equal -1 [lsearch $log "REENTRANCE_DETECTED"] - assert_equal {inner_after_outer outer_done} $log - } - - test {Per-key post notification job is refused on multi-key commands} { - r flushall - set repl [attach_to_replication_stream] - - # MSET touches multiple keys; AddPostNotificationJobForKey must refuse - # the registration from KSN, so no LPUSH side-effect is propagated. - r mset batched_a 1 batched_b 2 batched_c 3 - assert_equal {} [r lrange batched_keys 0 -1] - - assert_replication_stream $repl { - {select *} - {mset batched_a 1 batched_b 2 batched_c 3} - } - close_replication_stream $repl - } - - test {Per-key post notification job fires between HSET and HEXPIRE on the same hash inside MULTI/EXEC} { - r flushall - set repl [attach_to_replication_stream] - - # HSET and HEXPIRE both touch exactly one key, so they pass the - # single-key guard. The per-key callback fires at the tail of each - # sub-command's call(), interleaving an LPUSH side-effect between - # successive HSET/HEXPIRE on the same hash. This is the original - # motivation for the per-key API (RED-197766). - r multi - r hset hash_h f1 v1 - r hset hash_h f2 v2 - r hexpire hash_h 100 FIELDS 1 f1 - r exec - - assert_equal {hash_h hash_h hash_h} [r lrange hash_keys 0 -1] - - assert_replication_stream $repl { - {multi} - {select *} - {hset hash_h f1 v1} - {lpush hash_keys hash_h} - {hset hash_h f2 v2} - {lpush hash_keys hash_h} - {hpexpireat hash_h * FIELDS 1 f1} - {lpush hash_keys hash_h} - {exec} - } - close_replication_stream $repl - } - - test {Lazy expire registers a per-key post notification job} { - r flushall - r DEBUG SET-ACTIVE-EXPIRE 0 - set repl [attach_to_replication_stream] - - # GET on an expired key triggers a lazy DEL inside GET's call(). - # The DEL fires NOTIFY_EXPIRED with server.executing_client still - # set to the GET, so the single-key guard accepts the registration - # and the per-key callback LPUSHes the expired key to the sink. - r set expire_x 1 - r pexpire expire_x 1 - after 10 - assert_equal {} [r get expire_x] - - assert_equal {expire_x} [r lrange expired_keys 0 -1] - - assert_replication_stream $repl { - {select *} - {set expire_x 1} - {pexpireat expire_x *} - {multi} - {del expire_x} - {lpush expired_keys expire_x} - {exec} - } - close_replication_stream $repl - r DEBUG SET-ACTIVE-EXPIRE 1 - } {OK} {needs:debug} - - test {Lazy expire from inside an outer per-key callback registers a second per-key job} { - r flushall - r DEBUG SET-ACTIVE-EXPIRE 0 - set repl [attach_to_replication_stream] - - # Combined coverage of the reentrance guard and lazy-expire-driven - # KSN: SET read_expire_target's per-key callback issues GET - # expire_target, whose lazy DEL fires NOTIFY_EXPIRED and registers - # a second per-key job from inside the outer callback. That job - # must fire from the outer drain (not nested inside the outer - # callback's stack), which is what the reentrance guard combined - # with the per-call() firing hook makes possible. - r set expire_target 1 - r pexpire expire_target 1 - after 10 - r set read_expire_target 1 - - assert_equal {expire_target} [r lrange expired_keys 0 -1] - - assert_replication_stream $repl { - {select *} - {set expire_target 1} - {pexpireat expire_target *} - {multi} - {set read_expire_target 1} - {del expire_target} - {lpush expired_keys expire_target} - {exec} - } - close_replication_stream $repl - r DEBUG SET-ACTIVE-EXPIRE 1 - } {OK} {needs:debug} - } -} From 0ed8e779001736992fa097fe701d22be96404876 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Fri, 22 May 2026 12:27:46 +0200 Subject: [PATCH 09/24] add logging when more than 1 key is used --- src/module.c | 10 +++++++++- tests/unit/moduleapi/postnotifications.tcl | 9 ++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/module.c b/src/module.c index 07bc9946d..50ba73350 100644 --- a/src/module.c +++ b/src/module.c @@ -9551,7 +9551,15 @@ int RM_AddPostNotificationJobForKey(RedisModuleCtx *ctx, RedisModulePostNotifica getKeysFromCommand(ec->cmd, ec->argv, ec->argc, &result); int numkeys = result.numkeys; getKeysFreeResult(&result); - if (numkeys != 1) return REDISMODULE_ERR; + if (numkeys != 1) { + serverLog(LL_WARNING, + "API misuse detected in module %s: " + "RedisModule_AddPostNotificationJobForKey called from a notification " + "on command '%s' which touches %d keys; the per-key API requires " + "exactly one key.", + ctx->module->name, ec->cmd->fullname, numkeys); + return REDISMODULE_ERR; + } RedisModulePostKeyedNotificationJob *job = zmalloc(sizeof(*job)); job->module = ctx->module; diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index f314554ad..f4f8db7e7 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -270,14 +270,21 @@ tags "modules external:skip" { # Per-key-only: the single-key guard refuses registration when the # current command touches more than one key. The regular API has no - # such constraint. + # such constraint. We also assert that the refusal logs a warning so + # module authors get a hint when they hit this. test {Per-key post notification job is refused on multi-key commands} { r flushall set repl [attach_to_replication_stream] + set baseline [count_log_message 0 "AddPostNotificationJobForKey"] r mset batched_a 1 batched_b 2 batched_c 3 assert_equal {} [r lrange batched_keys 0 -1] + # MSET touches 3 keys; the keyspace handler fires once per key, so + # the warning is emitted three times. + set after [count_log_message 0 "AddPostNotificationJobForKey"] + assert_equal 3 [expr {$after - $baseline}] + assert_replication_stream $repl { {select *} {mset batched_a 1 batched_b 2 batched_c 3} From 6f617687ba98f8f55065ee701340726d613316ef Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Fri, 22 May 2026 13:01:47 +0200 Subject: [PATCH 10/24] refactor tests --- tests/modules/postnotifications.c | 32 +-- tests/unit/moduleapi/postnotifications.tcl | 214 ++++++++++++--------- 2 files changed, 140 insertions(+), 106 deletions(-) diff --git a/tests/modules/postnotifications.c b/tests/modules/postnotifications.c index 2a9b721c7..f0d10d4ce 100644 --- a/tests/modules/postnotifications.c +++ b/tests/modules/postnotifications.c @@ -152,30 +152,40 @@ static int KeySpace_NestedNotification(RedisModuleCtx *ctx, int type, const char return REDISMODULE_OK; } +typedef struct AsyncSetArgs { + RedisModuleBlockedClient *bc; + RedisModuleString *key; +} AsyncSetArgs; + static void *KeySpace_PostNotificationsAsyncSetInner(void *arg) { - RedisModuleBlockedClient *bc = arg; - RedisModuleCtx *ctx = RedisModule_GetThreadSafeContext(bc); + AsyncSetArgs *args = arg; + RedisModuleCtx *ctx = RedisModule_GetThreadSafeContext(args->bc); RedisModule_ThreadSafeContextLock(ctx); - RedisModuleCallReply* rep = RedisModule_Call(ctx, "set", "!cc", "string_x", "1"); + RedisModuleCallReply* rep = RedisModule_Call(ctx, "set", "!sc", args->key, "1"); RedisModule_ThreadSafeContextUnlock(ctx); RedisModule_ReplyWithCallReply(ctx, rep); RedisModule_FreeCallReply(rep); - RedisModule_UnblockClient(bc, NULL); + RedisModule_UnblockClient(args->bc, NULL); RedisModule_FreeThreadSafeContext(ctx); + RedisModule_FreeString(NULL, args->key); + RedisModule_Free(args); return NULL; } static int KeySpace_PostNotificationsAsyncSet(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { - REDISMODULE_NOT_USED(argv); - if (argc != 1) + if (argc != 2) return RedisModule_WrongArity(ctx); - pthread_t tid; - RedisModuleBlockedClient *bc = RedisModule_BlockClient(ctx,NULL,NULL,NULL,0); + AsyncSetArgs *args = RedisModule_Alloc(sizeof(*args)); + args->bc = RedisModule_BlockClient(ctx,NULL,NULL,NULL,0); + args->key = RedisModule_HoldString(NULL, argv[1]); - if (pthread_create(&tid,NULL,KeySpace_PostNotificationsAsyncSetInner,bc) != 0) { - RedisModule_AbortBlock(bc); + pthread_t tid; + if (pthread_create(&tid,NULL,KeySpace_PostNotificationsAsyncSetInner,args) != 0) { + RedisModule_AbortBlock(args->bc); + RedisModule_FreeString(NULL, args->key); + RedisModule_Free(args); return RedisModule_ReplyWithError(ctx,"-ERR Can't start thread"); } pthread_detach(tid); @@ -437,7 +447,7 @@ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) } if (RedisModule_CreateCommand(ctx, "postnotification.async_set", KeySpace_PostNotificationsAsyncSet, - "write", 0, 0, 0) == REDISMODULE_ERR){ + "write", 1, 1, 1) == REDISMODULE_ERR){ return REDISMODULE_ERR; } diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index f4f8db7e7..e5f49d589 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -19,60 +19,86 @@ tags "modules external:skip" { # which also fires the RedisModuleEvent_Key.before_overwritten server # event (registered first during dbGenericDelete inside setKey, before # notifyKeyspaceEvent). - # - # In the regular iteration both side effects ride the regular drain - # (server-event LPUSH first, KSN INCRs second). In the per-key - # iteration the per-key drain runs first in afterCommand, so the - # per-key LPUSH appears before the server-event LPUSH. - foreach {api setup_key drain_ops} { - regular string_x {{incr string_changed{string_x}} {incr string_total}} - perkey batched_a {{lpush batched_keys batched_a}} + foreach {api key} { + regular string_x + perkey batched_a } { test "Post-notification job fires on writes ($api API)" { r flushall set repl [attach_to_replication_stream] - r set $setup_key 1 - r set $setup_key 2 + r set $key 1 + r set $key 2 - set expected [list {multi} {select *} [list set $setup_key 1]] - foreach op $drain_ops { lappend expected $op } - lappend expected {exec} - - lappend expected {multi} [list set $setup_key 2] if {$api eq "regular"} { - lappend expected [list lpush before_overwritten $setup_key] - foreach op $drain_ops { lappend expected $op } + assert_replication_stream $repl { + {multi} + {select *} + {set string_x 1} + {incr string_changed{string_x}} + {incr string_total} + {exec} + {multi} + {set string_x 2} + {lpush before_overwritten string_x} + {incr string_changed{string_x}} + {incr string_total} + {exec} + } } else { - foreach op $drain_ops { lappend expected $op } - lappend expected [list lpush before_overwritten $setup_key] + assert_replication_stream $repl { + {multi} + {select *} + {set batched_a 1} + {lpush batched_keys batched_a} + {exec} + {multi} + {set batched_a 2} + {lpush batched_keys batched_a} + {lpush before_overwritten batched_a} + {exec} + } } - lappend expected {exec} - - assert_replication_stream $repl $expected close_replication_stream $repl } } - # Regular-only: the per-key API can't fire from a thread because it - # requires server.executing_client to be set (single-key guard). - test {Test write on post notification callback from module thread} { - r flushall - set repl [attach_to_replication_stream] + # Common: an RM_Call from a module thread (after ThreadSafeContextLock) + # goes through call() and sets server.executing_client, so both APIs + # accept the registration. + foreach {api key} { + regular string_x + perkey batched_a + } { + test "Test write on post notification callback from module thread ($api API)" { + r flushall + set repl [attach_to_replication_stream] - assert_equal {OK} [r postnotification.async_set] - assert_equal {1} [r get string_changed{string_x}] - assert_equal {1} [r get string_total] + assert_equal {OK} [r postnotification.async_set $key] - assert_replication_stream $repl { - {multi} - {select *} - {set string_x 1} - {incr string_changed{string_x}} - {incr string_total} - {exec} + if {$api eq "regular"} { + assert_equal {1} [r get string_changed{string_x}] + assert_equal {1} [r get string_total] + assert_replication_stream $repl { + {multi} + {select *} + {set string_x 1} + {incr string_changed{string_x}} + {incr string_total} + {exec} + } + } else { + assert_equal $key [r lindex batched_keys 0] + assert_replication_stream $repl { + {multi} + {select *} + {set batched_a 1} + {lpush batched_keys batched_a} + {exec} + } + } + close_replication_stream $repl } - close_replication_stream $repl } # Regular-only: active expire fires from cron with no executing_client, @@ -108,12 +134,10 @@ tags "modules external:skip" { # Common: lazy DEL on key access fires NOTIFY_EXPIRED with # server.executing_client still set, so a post-notification job # registered from inside the handler is queued and propagated as part - # of the same execution unit. Iterates over the legacy regular fixture - # (key "x", INCR-counter sink) and the per-key fixture (key - # "expire_x", LPUSH-list sink). - foreach {api key drain_op} { - regular x {incr expired} - perkey expire_x {lpush expired_keys expire_x} + # of the same execution unit. + foreach {api key} { + regular x + perkey expire_x } { test "Test lazy expire ($api API)" { r flushall @@ -125,32 +149,28 @@ tags "modules external:skip" { after 10 assert_equal {} [r get $key] - # before_expired comes from the RedisModuleEvent_Key server event, - # registered during dbGenericDelete BEFORE notifyKeyspaceEvent. - # Regular iteration: both side effects ride the regular drain; - # server-event LPUSH appears before the KSN-driven INCR. Per-key - # iteration: per-key drain runs first in afterCommand, so the - # per-key LPUSH appears before the server-event LPUSH. if {$api eq "regular"} { - assert_replication_stream $repl [list \ - {select *} \ - [list set $key 1] \ - [list pexpireat $key *] \ - {multi} \ - [list del $key] \ - [list lpush before_expired $key] \ - $drain_op \ - {exec}] + assert_replication_stream $repl { + {select *} + {set x 1} + {pexpireat x *} + {multi} + {del x} + {lpush before_expired x} + {incr expired} + {exec} + } } else { - assert_replication_stream $repl [list \ - {select *} \ - [list set $key 1] \ - [list pexpireat $key *] \ - {multi} \ - [list del $key] \ - $drain_op \ - [list lpush before_expired $key] \ - {exec}] + assert_replication_stream $repl { + {select *} + {set expire_x 1} + {pexpireat expire_x *} + {multi} + {del expire_x} + {lpush expired_keys expire_x} + {lpush before_expired expire_x} + {exec} + } } close_replication_stream $repl r DEBUG SET-ACTIVE-EXPIRE 1 @@ -164,9 +184,9 @@ tags "modules external:skip" { # inside the outer callback's stack. The regular API gates this via # execution_nesting; the per-key API gates it via the dedicated # firing_keyed_post_notif_jobs flag. - foreach {api outer_key inner_key drain_op} { - regular read_x x {incr expired} - perkey pkread_expire_target expire_target {lpush expired_keys expire_target} + foreach {api outer_key inner_key} { + regular read_x x + perkey pkread_expire_target expire_target } { test "Test lazy expire inside post job notification ($api API)" { r flushall @@ -179,27 +199,29 @@ tags "modules external:skip" { assert_equal {OK} [r set $outer_key 1] if {$api eq "regular"} { - assert_replication_stream $repl [list \ - {select *} \ - [list set $inner_key 1] \ - [list pexpireat $inner_key *] \ - {multi} \ - [list set $outer_key 1] \ - [list del $inner_key] \ - [list lpush before_expired $inner_key] \ - $drain_op \ - {exec}] + assert_replication_stream $repl { + {select *} + {set x 1} + {pexpireat x *} + {multi} + {set read_x 1} + {del x} + {lpush before_expired x} + {incr expired} + {exec} + } } else { - assert_replication_stream $repl [list \ - {select *} \ - [list set $inner_key 1] \ - [list pexpireat $inner_key *] \ - {multi} \ - [list set $outer_key 1] \ - [list del $inner_key] \ - $drain_op \ - [list lpush before_expired $inner_key] \ - {exec}] + assert_replication_stream $repl { + {select *} + {set expire_target 1} + {pexpireat expire_target *} + {multi} + {set pkread_expire_target 1} + {del expire_target} + {lpush expired_keys expire_target} + {lpush before_expired expire_target} + {exec} + } } close_replication_stream $repl r DEBUG SET-ACTIVE-EXPIRE 1 @@ -323,10 +345,12 @@ tags "modules external:skip" { close_replication_stream $repl } - # Regular-only: eviction triggers from performEvictions with no - # executing_client for the evicted key, so the per-key API's - # single-key guard refuses the registration. Placed last in the - # section because it sets maxmemory=1 and OOMs subsequent writes. + # Both APIs accept registrations from a NOTIFY_EVICTED handler + # because eviction fires inside the OOM-triggering command's call(), + # so server.executing_client is set. Tested only via the regular + # fixture for brevity — the per-key path is structurally identical. + # Placed last in the section because it sets maxmemory=1 and would + # OOM subsequent writes. test {Test eviction} { r flushall set repl [attach_to_replication_stream] From 6cad4bc894b9027764d4ccfcd7d4e785545bd739 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Fri, 22 May 2026 13:10:04 +0200 Subject: [PATCH 11/24] reorder tests --- tests/unit/moduleapi/postnotifications.tcl | 60 ++++++++++++---------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index e5f49d589..18c95d9f1 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -23,7 +23,7 @@ tags "modules external:skip" { regular string_x perkey batched_a } { - test "Post-notification job fires on writes ($api API)" { + test "Test write on post notification callback ($api API)" { r flushall set repl [attach_to_replication_stream] @@ -31,6 +31,9 @@ tags "modules external:skip" { r set $key 2 if {$api eq "regular"} { + assert_equal {2} [r get string_changed{string_x}] + assert_equal {2} [r get string_total] + # the {lpush before_overwritten string_x} is a post notification job registered when 'string_x' was overwritten assert_replication_stream $repl { {multi} {select *} @@ -46,6 +49,7 @@ tags "modules external:skip" { {exec} } } else { + assert_equal {batched_a batched_a} [r lrange batched_keys 0 -1] assert_replication_stream $repl { {multi} {select *} @@ -247,6 +251,33 @@ tags "modules external:skip" { close_replication_stream $repl } + # Both APIs accept registrations from a NOTIFY_EVICTED handler + # because eviction fires inside the OOM-triggering command's call(), + # so server.executing_client is set. Tested only via the regular + # fixture for brevity — the per-key path is structurally identical. + test {Test eviction} { + r flushall + set repl [attach_to_replication_stream] + r set x 1 + r config set maxmemory-policy allkeys-random + r config set maxmemory 1 + + assert_error {OOM *} {r set y 1} + + # the {lpush before_evicted x} is a post notification job registered before 'x' got evicted + assert_replication_stream $repl { + {select *} + {set x 1} + {multi} + {del x} + {lpush before_evicted x} + {incr evicted} + {exec} + } + r config set maxmemory 0 + close_replication_stream $repl + } {} {needs:config-maxmemory} + # Per-key-only: the per-key callback fires at the tail of EVERY call(), # including each sub-command inside MULTI/EXEC. The regular API only # fires at the outermost EXEC. @@ -345,33 +376,6 @@ tags "modules external:skip" { close_replication_stream $repl } - # Both APIs accept registrations from a NOTIFY_EVICTED handler - # because eviction fires inside the OOM-triggering command's call(), - # so server.executing_client is set. Tested only via the regular - # fixture for brevity — the per-key path is structurally identical. - # Placed last in the section because it sets maxmemory=1 and would - # OOM subsequent writes. - test {Test eviction} { - r flushall - set repl [attach_to_replication_stream] - r set x 1 - r config set maxmemory-policy allkeys-random - r config set maxmemory 1 - - assert_error {OOM *} {r set y 1} - - # the {lpush before_evicted x} is a post notification job registered before 'x' got evicted - assert_replication_stream $repl { - {select *} - {set x 1} - {multi} - {del x} - {lpush before_evicted x} - {incr evicted} - {exec} - } - close_replication_stream $repl - } {} {needs:config-maxmemory} } } From 540891a1f3b8f0b609a633d0b76e3c7046b9085b Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Fri, 22 May 2026 13:21:45 +0200 Subject: [PATCH 12/24] recover some asserts --- tests/unit/moduleapi/postnotifications.tcl | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index 18c95d9f1..5763e145c 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -28,8 +28,14 @@ tags "modules external:skip" { set repl [attach_to_replication_stream] r set $key 1 - r set $key 2 + if {$api eq "regular"} { + assert_equal {1} [r get string_changed{string_x}] + assert_equal {1} [r get string_total] + } else { + assert_equal {batched_a} [r lrange batched_keys 0 -1] + } + r set $key 2 if {$api eq "regular"} { assert_equal {2} [r get string_changed{string_x}] assert_equal {2} [r get string_total] From 4b403e47d45ef2b9a4ceb72e8d6c65b791eda034 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Fri, 22 May 2026 13:50:18 +0200 Subject: [PATCH 13/24] change the testing strategy --- tests/modules/postnotifications.c | 404 +++++++++++---------- tests/unit/moduleapi/postnotifications.tcl | 365 ++++++++----------- 2 files changed, 357 insertions(+), 412 deletions(-) diff --git a/tests/modules/postnotifications.c b/tests/modules/postnotifications.c index f0d10d4ce..ca26f3619 100644 --- a/tests/modules/postnotifications.c +++ b/tests/modules/postnotifications.c @@ -1,4 +1,20 @@ -/* This module is used to test the server post keyspace jobs API. +/* This module is used to test the server post-notification keyspace jobs API. + * + * It supports both APIs from a single .so: + * `RedisModule_AddPostNotificationJob` (the "regular" API) + * `RedisModule_AddPostNotificationJobForKey` (the "per-key" API) + * + * The API to register against is chosen via a required load arg: "regular" + * or "perkey". The keyspace handlers use the same key prefixes and produce + * the same post-job side effects in either mode — only the registration + * call differs. This lets the common tests parametrize over the two APIs + * without diverging in keys, asserts, or expected streams. + * + * An optional `with_key_events` arg subscribes to RedisModuleEvent_Key so + * tests can additionally observe `before_deleted`/`before_expired`/ + * `before_evicted`/`before_overwritten` interleaving with the + * post-notification drain. The server-event-driven post-jobs are always + * registered through the regular API (server events are not API-specific). * * ----------------------------------------------------------------------------- * @@ -10,21 +26,6 @@ * GNU Affero General Public License v3 (AGPLv3). */ -/* This module allow to verify 'RedisModule_AddPostNotificationJob' by registering to 3 - * key space event: - * * STRINGS - the module register to all strings notifications and set post notification job - * that increase a counter indicating how many times the string key was changed. - * In addition, it increase another counter that counts the total changes that - * was made on all strings keys. - * * EXPIRED - the module register to expired event and set post notification job that that - * counts the total number of expired events. - * * EVICTED - the module register to evicted event and set post notification job that that - * counts the total number of evicted events. - * - * In addition, the module register a new command, 'postnotification.async_set', that performs a set - * command from a background thread. This allows to check the 'RedisModule_AddPostNotificationJob' on - * notifications that was triggered on a background thread. */ - #define _BSD_SOURCE #define _DEFAULT_SOURCE /* For usleep */ @@ -34,124 +35,166 @@ #include #include -static void KeySpace_PostNotificationStringFreePD(void *pd) { +/* =========================================================================== + * Mode dispatcher: KSN handlers compute (key, target) and forward to one of + * the two APIs based on g_api_mode. The post-job effect is the same in both + * modes; only the registration call differs. + * ======================================================================== */ + +enum api_mode { + MODE_REGULAR, + MODE_PERKEY, +}; +static int g_api_mode = MODE_REGULAR; + +static void FreeHeldString(void *pd) { RedisModule_FreeString(NULL, pd); } -static void KeySpace_PostNotificationReadKey(RedisModuleCtx *ctx, void *pd) { - RedisModuleCallReply* rep = RedisModule_Call(ctx, "get", "!s", pd); - RedisModule_FreeCallReply(rep); +/* Effects */ + +static void DoIncr(RedisModuleCtx *ctx, RedisModuleString *target) { + RedisModuleCallReply *rep = RedisModule_Call(ctx, "incr", "!s", target); + if (rep) RedisModule_FreeCallReply(rep); } -static void KeySpace_PostNotificationString(RedisModuleCtx *ctx, void *pd) { - REDISMODULE_NOT_USED(ctx); - RedisModuleCallReply* rep = RedisModule_Call(ctx, "incr", "!s", pd); - RedisModule_FreeCallReply(rep); +static void DoGet(RedisModuleCtx *ctx, RedisModuleString *target) { + RedisModuleCallReply *rep = RedisModule_Call(ctx, "get", "!s", target); + if (rep) RedisModule_FreeCallReply(rep); } -static int KeySpace_NotificationExpired(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); +/* Regular-API callback wrappers — pd is the target RedisModuleString. */ - /* Per-key fixtures own the "expire_" prefix; let their dedicated handler - * react to lazy expire on those keys without this counter-style handler - * interfering. The parametrized "Test lazy expire" iterates over the - * legacy regular key ("x") and the per-key key ("expire_x") to assert - * parity across both APIs. */ - const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "expire_", 7) == 0) return REDISMODULE_OK; - - RedisModuleString *new_key = RedisModule_CreateString(NULL, "expired", 7); - int res = RedisModule_AddPostNotificationJob(ctx, KeySpace_PostNotificationString, new_key, KeySpace_PostNotificationStringFreePD); - if (res == REDISMODULE_ERR) KeySpace_PostNotificationStringFreePD(new_key); - return REDISMODULE_OK; +static void RegularJob_Incr(RedisModuleCtx *ctx, void *pd) { + DoIncr(ctx, (RedisModuleString *)pd); } -static int KeySpace_NotificationEvicted(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); +static void RegularJob_Get(RedisModuleCtx *ctx, void *pd) { + DoGet(ctx, (RedisModuleString *)pd); +} + +/* Per-key-API callback wrappers — pd is the target; the key argument is the + * notifying key (used only to satisfy the per-key API's signature). */ + +static void PerKeyJob_Incr(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { REDISMODULE_NOT_USED(key); + DoIncr(ctx, (RedisModuleString *)pd); +} - const char *key_str = RedisModule_StringPtrLen(key, NULL); +static void PerKeyJob_Get(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(key); + DoGet(ctx, (RedisModuleString *)pd); +} - if (strncmp(key_str, "evicted", 7) == 0) { - return REDISMODULE_OK; /* do not count the evicted key */ +/* Dispatchers — register a post-notification job using the active API. */ + +static int RegisterIncrJob(RedisModuleCtx *ctx, RedisModuleString *key, RedisModuleString *target) { + if (g_api_mode == MODE_REGULAR) { + return RedisModule_AddPostNotificationJob(ctx, RegularJob_Incr, target, FreeHeldString); } + return RedisModule_AddPostNotificationJobForKey(ctx, PerKeyJob_Incr, key, target, FreeHeldString); +} - if (strncmp(key_str, "before_evicted", 14) == 0) { - return REDISMODULE_OK; /* do not count the before_evicted key */ +static int RegisterGetJob(RedisModuleCtx *ctx, RedisModuleString *key, RedisModuleString *target) { + if (g_api_mode == MODE_REGULAR) { + return RedisModule_AddPostNotificationJob(ctx, RegularJob_Get, target, FreeHeldString); } + return RedisModule_AddPostNotificationJobForKey(ctx, PerKeyJob_Get, key, target, FreeHeldString); +} - RedisModuleString *new_key = RedisModule_CreateString(NULL, "evicted", 7); - int res = RedisModule_AddPostNotificationJob(ctx, KeySpace_PostNotificationString, new_key, KeySpace_PostNotificationStringFreePD); - if (res == REDISMODULE_ERR) KeySpace_PostNotificationStringFreePD(new_key); +/* =========================================================================== + * Mode-aware KSN handlers (registered in both modes). + * ======================================================================== */ + +/* "expired" event: register a post-job that INCRs an "expired" counter. */ +static int KeySpace_NotificationExpired(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + RedisModuleString *target = RedisModule_CreateString(NULL, "expired", 7); + int res = RegisterIncrJob(ctx, key, target); + if (res == REDISMODULE_ERR) FreeHeldString(target); return REDISMODULE_OK; } -static int KeySpace_NotificationString(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ - REDISMODULE_NOT_USED(ctx); +/* "evicted" event: register a post-job that INCRs an "evicted" counter. */ +static int KeySpace_NotificationEvicted(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { REDISMODULE_NOT_USED(type); REDISMODULE_NOT_USED(event); const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "evicted", 7) == 0) return REDISMODULE_OK; /* skip our sink */ + if (strncmp(key_str, "before_evicted", 14) == 0) return REDISMODULE_OK; /* skip server-event sink */ - if (strncmp(key_str, "string_", 7) != 0) { - return REDISMODULE_OK; - } + RedisModuleString *target = RedisModule_CreateString(NULL, "evicted", 7); + int res = RegisterIncrJob(ctx, key, target); + if (res == REDISMODULE_ERR) FreeHeldString(target); + return REDISMODULE_OK; +} - if (strcmp(key_str, "string_total") == 0) { - return REDISMODULE_OK; - } +/* "string" event on `string_` keys: register a post-job that INCRs the + * paired `string_changed{}` counter, which itself fires another KSN that + * cascades into INCR `string_total`. */ +static int KeySpace_NotificationString(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); - RedisModuleString *new_key; + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "string_", 7) != 0) return REDISMODULE_OK; + if (strcmp(key_str, "string_total") == 0) return REDISMODULE_OK; + + RedisModuleString *target; if (strncmp(key_str, "string_changed{", 15) == 0) { - new_key = RedisModule_CreateString(NULL, "string_total", 12); + target = RedisModule_CreateString(NULL, "string_total", 12); } else { - new_key = RedisModule_CreateStringPrintf(NULL, "string_changed{%s}", key_str); + target = RedisModule_CreateStringPrintf(NULL, "string_changed{%s}", key_str); } - int res = RedisModule_AddPostNotificationJob(ctx, KeySpace_PostNotificationString, new_key, KeySpace_PostNotificationStringFreePD); - if (res == REDISMODULE_ERR) KeySpace_PostNotificationStringFreePD(new_key); + int res = RegisterIncrJob(ctx, key, target); + if (res == REDISMODULE_ERR) FreeHeldString(target); return REDISMODULE_OK; } -static int KeySpace_LazyExpireInsidePostNotificationJob(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ - REDISMODULE_NOT_USED(ctx); +/* "string" event on `read_` keys: register a post-job that GETs `` — + * used to drive lazy expire on `` from inside a post-notification + * callback. */ +static int KeySpace_LazyExpireInsidePostNotificationJob(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { REDISMODULE_NOT_USED(type); REDISMODULE_NOT_USED(event); const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "read_", 5) != 0) return REDISMODULE_OK; - if (strncmp(key_str, "read_", 5) != 0) { - return REDISMODULE_OK; - } - - RedisModuleString *new_key = RedisModule_CreateString(NULL, key_str + 5, strlen(key_str) - 5);; - int res = RedisModule_AddPostNotificationJob(ctx, KeySpace_PostNotificationReadKey, new_key, KeySpace_PostNotificationStringFreePD); - if (res == REDISMODULE_ERR) KeySpace_PostNotificationStringFreePD(new_key); + RedisModuleString *target = RedisModule_CreateString(NULL, key_str + 5, strlen(key_str) - 5); + int res = RegisterGetJob(ctx, key, target); + if (res == REDISMODULE_ERR) FreeHeldString(target); return REDISMODULE_OK; } -static int KeySpace_NestedNotification(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ - REDISMODULE_NOT_USED(ctx); +/* "string" event on `write_sync_` keys: directly RM_Call SET 1 from + * inside the handler (no post-job). Exercises + * REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS, which is + * orthogonal to the post-notification APIs. */ +static int KeySpace_NestedNotification(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { REDISMODULE_NOT_USED(type); REDISMODULE_NOT_USED(event); const char *key_str = RedisModule_StringPtrLen(key, NULL); - - if (strncmp(key_str, "write_sync_", 11) != 0) { - return REDISMODULE_OK; - } + if (strncmp(key_str, "write_sync_", 11) != 0) return REDISMODULE_OK; /* This test was only meant to check REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS. - * In general it is wrong and discourage to perform any writes inside a notification callback. */ - RedisModuleString *new_key = RedisModule_CreateString(NULL, key_str + 11, strlen(key_str) - 11);; - RedisModuleCallReply* rep = RedisModule_Call(ctx, "set", "!sc", new_key, "1"); + * In general it is wrong and discouraged to perform any writes inside a notification callback. */ + RedisModuleString *new_key = RedisModule_CreateString(NULL, key_str + 11, strlen(key_str) - 11); + RedisModuleCallReply *rep = RedisModule_Call(ctx, "set", "!sc", new_key, "1"); RedisModule_FreeCallReply(rep); RedisModule_FreeString(NULL, new_key); return REDISMODULE_OK; } +/* =========================================================================== + * Async write from a module thread (mode-independent). + * ======================================================================== */ + typedef struct AsyncSetArgs { RedisModuleBlockedClient *bc; RedisModuleString *key; @@ -161,7 +204,7 @@ static void *KeySpace_PostNotificationsAsyncSetInner(void *arg) { AsyncSetArgs *args = arg; RedisModuleCtx *ctx = RedisModule_GetThreadSafeContext(args->bc); RedisModule_ThreadSafeContextLock(ctx); - RedisModuleCallReply* rep = RedisModule_Call(ctx, "set", "!sc", args->key, "1"); + RedisModuleCallReply *rep = RedisModule_Call(ctx, "set", "!sc", args->key, "1"); RedisModule_ThreadSafeContextUnlock(ctx); RedisModule_ReplyWithCallReply(ctx, rep); RedisModule_FreeCallReply(rep); @@ -178,20 +221,26 @@ static int KeySpace_PostNotificationsAsyncSet(RedisModuleCtx *ctx, RedisModuleSt return RedisModule_WrongArity(ctx); AsyncSetArgs *args = RedisModule_Alloc(sizeof(*args)); - args->bc = RedisModule_BlockClient(ctx,NULL,NULL,NULL,0); + args->bc = RedisModule_BlockClient(ctx, NULL, NULL, NULL, 0); args->key = RedisModule_HoldString(NULL, argv[1]); pthread_t tid; - if (pthread_create(&tid,NULL,KeySpace_PostNotificationsAsyncSetInner,args) != 0) { + if (pthread_create(&tid, NULL, KeySpace_PostNotificationsAsyncSetInner, args) != 0) { RedisModule_AbortBlock(args->bc); RedisModule_FreeString(NULL, args->key); RedisModule_Free(args); - return RedisModule_ReplyWithError(ctx,"-ERR Can't start thread"); + return RedisModule_ReplyWithError(ctx, "-ERR Can't start thread"); } pthread_detach(tid); return REDISMODULE_OK; } +/* =========================================================================== + * Server-event handler: subscribes to RedisModuleEvent_Key when + * `with_key_events` is passed at load time. Always uses the regular API — + * server events are not API-specific. + * ======================================================================== */ + typedef struct KeySpace_EventPostNotificationCtx { RedisModuleString *triggered_on; RedisModuleString *new_key; @@ -207,7 +256,7 @@ static void KeySpace_ServerEventPostNotificationFree(void *pd) { static void KeySpace_ServerEventPostNotification(RedisModuleCtx *ctx, void *pd) { REDISMODULE_NOT_USED(ctx); KeySpace_EventPostNotificationCtx *pn_ctx = pd; - RedisModuleCallReply* rep = RedisModule_Call(ctx, "lpush", "!ss", pn_ctx->new_key, pn_ctx->triggered_on); + RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!ss", pn_ctx->new_key, pn_ctx->triggered_on); RedisModule_FreeCallReply(rep); } @@ -218,7 +267,7 @@ static void KeySpace_ServerEventCallback(RedisModuleCtx *ctx, RedisModuleEvent e RedisModule_Log(ctx, "warning", "Got an unexpected subevent '%llu'", (unsigned long long)subevent); return; } - static const char* events[] = { + static const char *events[] = { "before_deleted", "before_expired", "before_evicted", @@ -228,9 +277,9 @@ static void KeySpace_ServerEventCallback(RedisModuleCtx *ctx, RedisModuleEvent e const RedisModuleString *key_name = RedisModule_GetKeyNameFromModuleKey(((RedisModuleKeyInfo*)data)->key); const char *key_str = RedisModule_StringPtrLen(key_name, NULL); - for (int i = 0 ; i < 4 ; ++i) { + for (int i = 0; i < 4; ++i) { const char *event = events[i]; - if (strncmp(key_str, event , strlen(event)) == 0) { + if (strncmp(key_str, event, strlen(event)) == 0) { return; /* don't log any event on our tracking keys */ } } @@ -242,18 +291,14 @@ static void KeySpace_ServerEventCallback(RedisModuleCtx *ctx, RedisModuleEvent e if (res == REDISMODULE_ERR) KeySpace_ServerEventPostNotificationFree(pn_ctx); } -/* ============================================================================ - * Per-key post-notification fixtures (RedisModule_AddPostNotificationJobForKey). - * - * Per-key callbacks fire at the tail of every call() — including each - * sub-command inside MULTI/EXEC — and registrations are restricted to commands - * that touch exactly one key. The fixtures below mirror the regular-API - * fixtures above, but exercise the per-key API surface, so a single .so can - * cover both APIs in one test file. - * ========================================================================= */ +/* =========================================================================== + * Per-key-only fixtures: behaviors that don't exist on the regular API + * (firing inside MULTI/EXEC, reentrance guard, multi-key refusal, hash + * subkey interleaving). Registered only in MODE_PERKEY. + * ======================================================================== */ -/* "batched_" path: each keyed callback appends the touched key to a sink list. - * Used to assert per-sub-command firing inside MULTI/EXEC and to assert the +/* "batched_" path: each keyed callback appends the touched key to a sink + * list. Used to assert per-sub-command firing inside MULTI/EXEC and the * single-key guard on multi-key commands. */ static void KeySpace_PostNotificationBatchedKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { REDISMODULE_NOT_USED(pd); @@ -273,10 +318,9 @@ static int KeySpace_NotificationBatched(RedisModuleCtx *ctx, int type, const cha return REDISMODULE_OK; } -/* "hash_" path: HSET/HEXPIRE both fire NOTIFY_HASH against a single hash key, - * so they pass the single-key guard. Used to assert that the per-key callback - * fires between successive HSET/HEXPIRE sub-commands on the same hash inside - * MULTI/EXEC (the original motivation for this API — RED-197766). */ +/* "hash_" path: HSET/HEXPIRE both fire NOTIFY_HASH against a single hash + * key, so they pass the single-key guard. Used to assert per-sub-command + * firing between HSET and HEXPIRE on the same hash inside MULTI/EXEC. */ static void KeySpace_PostNotificationHashKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { REDISMODULE_NOT_USED(pd); RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "hash_keys", key); @@ -294,59 +338,8 @@ static int KeySpace_NotificationHash(RedisModuleCtx *ctx, int type, const char * return REDISMODULE_OK; } -/* "expire_" path: NOTIFY_EXPIRED fires on the lazy-DEL path with - * server.executing_client still pointing at the accessing command, so the - * single-key guard accepts the registration. The callback LPUSHes the expired - * key name to a sink list. Paired with the legacy regular fixture (key "x", - * INCR-counter shape) for the parametrized "Test lazy expire" test. */ -static void KeySpace_PostNotificationExpiredPerKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { - REDISMODULE_NOT_USED(pd); - RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "expired_keys", key); - if (rep) RedisModule_FreeCallReply(rep); -} - -static int KeySpace_NotificationExpiredPerKey(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); - - const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "expire_", 7) != 0) return REDISMODULE_OK; - - RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationExpiredPerKey, key, NULL, NULL); - return REDISMODULE_OK; -} - -/* "pkread_" path: the outer per-key callback for a "pkread_" key issues - * a GET on . If is TTL-expired, that GET triggers a lazy DEL, - * which fires NOTIFY_EXPIRED and registers a second per-key job from inside - * the outer callback. The second job must fire from the outer drain (not - * nested inside the outer callback's stack) — the reentrance guard combined - * with the per-call() firing hook is what makes that work. Distinct from the - * regular module's "read_" prefix to keep their drains independent. */ -static void KeySpace_PostNotificationPerKeyRead(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { - REDISMODULE_NOT_USED(pd); - const char *key_str = RedisModule_StringPtrLen(key, NULL); - const char *target = key_str + 7; /* strip "pkread_" */ - RedisModuleCallReply *rep = RedisModule_Call(ctx, "get", "!c", target); - if (rep) RedisModule_FreeCallReply(rep); -} - -static int KeySpace_NotificationPerKeyRead(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); - - const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "pkread_", 7) != 0) return REDISMODULE_OK; - - RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationPerKeyRead, key, NULL, NULL); - return REDISMODULE_OK; -} - -/* "reentrant_" path: probes that the firing function does not re-enter while a - * nested RM_Call is in flight. The outer branch raises a marker, issues a - * nested SET (which registers a second keyed job via KSN), then lowers the - * marker. If re-entrance happened, the inner branch would observe marker==1 - * and log REENTRANCE_DETECTED. */ +/* "reentrant_" path: probes that the firing function does not re-enter + * while a nested RM_Call is in flight. */ static int reentrance_in_outer_callback = 0; static void KeySpace_PostNotificationReentranceProbe(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { @@ -379,13 +372,14 @@ static int KeySpace_NotificationReentrance(RedisModuleCtx *ctx, int type, const return REDISMODULE_OK; } -/* This function must be present on each Redis module. It is used in order to - * register the commands into the Redis server. */ -int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { - REDISMODULE_NOT_USED(argv); - REDISMODULE_NOT_USED(argc); +/* =========================================================================== + * OnLoad: parse args, set mode, subscribe handlers. + * Required arg: "regular" | "perkey". + * Optional arg: "with_key_events". + * ======================================================================== */ - if (RedisModule_Init(ctx,"postnotifications",1,REDISMODULE_APIVER_1) == REDISMODULE_ERR){ +int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { + if (RedisModule_Init(ctx, "postnotifications", 1, REDISMODULE_APIVER_1) == REDISMODULE_ERR) { return REDISMODULE_ERR; } @@ -393,61 +387,69 @@ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) return REDISMODULE_ERR; } + int mode_set = 0; int with_key_events = 0; - if (argc >= 1) { - const char *arg = RedisModule_StringPtrLen(argv[0], 0); - if (strcmp(arg, "with_key_events") == 0) { + for (int i = 0; i < argc; i++) { + const char *arg = RedisModule_StringPtrLen(argv[i], NULL); + if (strcmp(arg, "regular") == 0) { + g_api_mode = MODE_REGULAR; + mode_set = 1; + } else if (strcmp(arg, "perkey") == 0) { + g_api_mode = MODE_PERKEY; + mode_set = 1; + } else if (strcmp(arg, "with_key_events") == 0) { with_key_events = 1; + } else { + RedisModule_Log(ctx, "warning", "Unknown load arg '%s'", arg); + return REDISMODULE_ERR; } } + if (!mode_set) { + RedisModule_Log(ctx, "warning", "postnotifications module requires a mode arg ('regular' or 'perkey')."); + return REDISMODULE_ERR; + } RedisModule_SetModuleOptions(ctx, REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS); - if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationString) != REDISMODULE_OK){ + /* Mode-aware KSN handlers — registered in both modes, dispatch internally. */ + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationString) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_LazyExpireInsidePostNotificationJob) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NestedNotification) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_EXPIRED, KeySpace_NotificationExpired) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_EVICTED, KeySpace_NotificationEvicted) != REDISMODULE_OK) { return REDISMODULE_ERR; } - if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_LazyExpireInsidePostNotificationJob) != REDISMODULE_OK){ - return REDISMODULE_ERR; - } - - if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NestedNotification) != REDISMODULE_OK){ - return REDISMODULE_ERR; - } - - if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_EXPIRED, KeySpace_NotificationExpired) != REDISMODULE_OK){ - return REDISMODULE_ERR; - } - - if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_EVICTED, KeySpace_NotificationEvicted) != REDISMODULE_OK){ - return REDISMODULE_ERR; - } - - if (with_key_events) { - if(RedisModule_SubscribeToServerEvent(ctx, RedisModuleEvent_Key, KeySpace_ServerEventCallback) != REDISMODULE_OK){ + /* Per-key-only fixtures (behaviors with no regular API equivalent). */ + if (g_api_mode == MODE_PERKEY) { + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationBatched) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_HASH, KeySpace_NotificationHash) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationReentrance) != REDISMODULE_OK) { return REDISMODULE_ERR; } } - /* Per-key API subscriptions (and the "r_expire_" parity twin). */ - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationBatched) != REDISMODULE_OK) { - return REDISMODULE_ERR; - } - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_HASH, KeySpace_NotificationHash) != REDISMODULE_OK) { - return REDISMODULE_ERR; - } - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_EXPIRED, KeySpace_NotificationExpiredPerKey) != REDISMODULE_OK) { - return REDISMODULE_ERR; - } - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationPerKeyRead) != REDISMODULE_OK) { - return REDISMODULE_ERR; - } - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationReentrance) != REDISMODULE_OK) { - return REDISMODULE_ERR; + /* Optional server-event subscription. Always registers regular post-jobs. */ + if (with_key_events) { + if (RedisModule_SubscribeToServerEvent(ctx, RedisModuleEvent_Key, KeySpace_ServerEventCallback) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } } if (RedisModule_CreateCommand(ctx, "postnotification.async_set", KeySpace_PostNotificationsAsyncSet, - "write", 1, 1, 1) == REDISMODULE_ERR){ + "write", 1, 1, 1) == REDISMODULE_ERR) { return REDISMODULE_ERR; } diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index 5763e145c..ed08aad42 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -1,118 +1,164 @@ set testmodule [file normalize tests/modules/postnotifications.so] # ---------------------------------------------------------------------------- -# Both post-notification APIs (RM_AddPostNotificationJob and -# RM_AddPostNotificationJobForKey) share the same .so. Tests that exercise -# behavior identical across both APIs — i.e. anything that doesn't depend on -# the per-key API's distinct firing point inside MULTI/EXEC — are written as a -# `foreach` over (regular, perkey) so the same assertions hold for both. -# API-specific tests stay as standalone tests. +# The test module accepts a "regular" or "perkey" load arg that selects which +# post-notification API its handlers register against. The handlers' key +# prefixes and post-job side effects are identical in either mode — only the +# RM_AddPostNotificationJob vs RM_AddPostNotificationJobForKey call differs. +# That lets the common tests use identical keys, asserts, and expected +# replication streams for both APIs by wrapping the start_server itself in a +# foreach. +# +# `with_key_events` is loaded *only* by the regular-only block, since the +# server-event LPUSHes (`before_*`) ride the regular drain in both modes but +# their position differs vs. the per-key drain. Server-event interleaving is +# tested explicitly in that block. # ---------------------------------------------------------------------------- -tags "modules external:skip" { - start_server {} { - r module load $testmodule with_key_events +foreach api {regular perkey} { + tags "modules external:skip" { + start_server {} { + r module load $testmodule $api - # Common: a single SET fires the post-notification job registered by - # the KSN handler, and the side effect is propagated wrapped with the - # SET in one implicit MULTI/EXEC. The second SET overwrites the key, - # which also fires the RedisModuleEvent_Key.before_overwritten server - # event (registered first during dbGenericDelete inside setKey, before - # notifyKeyspaceEvent). - foreach {api key} { - regular string_x - perkey batched_a - } { test "Test write on post notification callback ($api API)" { r flushall set repl [attach_to_replication_stream] - r set $key 1 - if {$api eq "regular"} { - assert_equal {1} [r get string_changed{string_x}] - assert_equal {1} [r get string_total] - } else { - assert_equal {batched_a} [r lrange batched_keys 0 -1] - } + r set string_x 1 + assert_equal {1} [r get string_changed{string_x}] + assert_equal {1} [r get string_total] - r set $key 2 - if {$api eq "regular"} { - assert_equal {2} [r get string_changed{string_x}] - assert_equal {2} [r get string_total] - # the {lpush before_overwritten string_x} is a post notification job registered when 'string_x' was overwritten - assert_replication_stream $repl { - {multi} - {select *} - {set string_x 1} - {incr string_changed{string_x}} - {incr string_total} - {exec} - {multi} - {set string_x 2} - {lpush before_overwritten string_x} - {incr string_changed{string_x}} - {incr string_total} - {exec} - } - } else { - assert_equal {batched_a batched_a} [r lrange batched_keys 0 -1] - assert_replication_stream $repl { - {multi} - {select *} - {set batched_a 1} - {lpush batched_keys batched_a} - {exec} - {multi} - {set batched_a 2} - {lpush batched_keys batched_a} - {lpush before_overwritten batched_a} - {exec} - } + r set string_x 2 + assert_equal {2} [r get string_changed{string_x}] + assert_equal {2} [r get string_total] + + assert_replication_stream $repl { + {multi} + {select *} + {set string_x 1} + {incr string_changed{string_x}} + {incr string_total} + {exec} + {multi} + {set string_x 2} + {incr string_changed{string_x}} + {incr string_total} + {exec} } close_replication_stream $repl } - } - # Common: an RM_Call from a module thread (after ThreadSafeContextLock) - # goes through call() and sets server.executing_client, so both APIs - # accept the registration. - foreach {api key} { - regular string_x - perkey batched_a - } { test "Test write on post notification callback from module thread ($api API)" { r flushall set repl [attach_to_replication_stream] - assert_equal {OK} [r postnotification.async_set $key] + assert_equal {OK} [r postnotification.async_set string_x] + assert_equal {1} [r get string_changed{string_x}] + assert_equal {1} [r get string_total] - if {$api eq "regular"} { - assert_equal {1} [r get string_changed{string_x}] - assert_equal {1} [r get string_total] - assert_replication_stream $repl { - {multi} - {select *} - {set string_x 1} - {incr string_changed{string_x}} - {incr string_total} - {exec} - } - } else { - assert_equal $key [r lindex batched_keys 0] - assert_replication_stream $repl { - {multi} - {select *} - {set batched_a 1} - {lpush batched_keys batched_a} - {exec} - } + assert_replication_stream $repl { + {multi} + {select *} + {set string_x 1} + {incr string_changed{string_x}} + {incr string_total} + {exec} } close_replication_stream $repl } + + test "Test lazy expire ($api API)" { + r flushall + r DEBUG SET-ACTIVE-EXPIRE 0 + set repl [attach_to_replication_stream] + + r set x 1 + r pexpire x 1 + after 10 + assert_equal {} [r get x] + + assert_replication_stream $repl { + {select *} + {set x 1} + {pexpireat x *} + {multi} + {del x} + {incr expired} + {exec} + } + close_replication_stream $repl + r DEBUG SET-ACTIVE-EXPIRE 1 + } {OK} {needs:debug} + + test "Test lazy expire inside post job notification ($api API)" { + r flushall + r DEBUG SET-ACTIVE-EXPIRE 0 + set repl [attach_to_replication_stream] + + r set x 1 + r pexpire x 1 + after 10 + assert_equal {OK} [r set read_x 1] + + assert_replication_stream $repl { + {select *} + {set x 1} + {pexpireat x *} + {multi} + {set read_x 1} + {del x} + {incr expired} + {exec} + } + close_replication_stream $repl + r DEBUG SET-ACTIVE-EXPIRE 1 + } {OK} {needs:debug} + } + } +} + +# Regular-only tests: behaviors that depend on the regular API specifically +# (server events register via the regular API), or that the per-key API +# refuses outright (active expire fires from cron with executing_client=NULL), +# or that are orthogonal to the post-notification API (nested KSN). +tags "modules external:skip" { + start_server {} { + r module load $testmodule regular with_key_events + + test {Server event before_overwritten interleaves with the KSN-driven post-notification job} { + r flushall + set repl [attach_to_replication_stream] + + # The first SET creates string_x. The second SET overwrites it, + # which fires the RedisModuleEvent_Key.before_overwritten server + # event. That server event registers a post-job (via the regular + # API) that LPUSHes string_x into `before_overwritten`. Both side + # effects ride the regular drain — the server-event LPUSH was + # registered first (inside dbGenericDelete, before + # notifyKeyspaceEvent), so it appears before the KSN-driven INCRs. + r set string_x 1 + r set string_x 2 + + assert_replication_stream $repl { + {multi} + {select *} + {set string_x 1} + {incr string_changed{string_x}} + {incr string_total} + {exec} + {multi} + {set string_x 2} + {lpush before_overwritten string_x} + {incr string_changed{string_x}} + {incr string_total} + {exec} + } + close_replication_stream $repl } - # Regular-only: active expire fires from cron with no executing_client, - # so the per-key API's single-key guard refuses the registration. + # True API divergence: active expire fires from cron with + # server.executing_client == NULL, so the per-key API's single-key + # guard refuses the registration. Regular-only by construction. test {Test active expire} { r flushall set repl [attach_to_replication_stream] @@ -141,105 +187,8 @@ tags "modules external:skip" { close_replication_stream $repl } - # Common: lazy DEL on key access fires NOTIFY_EXPIRED with - # server.executing_client still set, so a post-notification job - # registered from inside the handler is queued and propagated as part - # of the same execution unit. - foreach {api key} { - regular x - perkey expire_x - } { - test "Test lazy expire ($api API)" { - r flushall - r DEBUG SET-ACTIVE-EXPIRE 0 - set repl [attach_to_replication_stream] - - r set $key 1 - r pexpire $key 1 - after 10 - assert_equal {} [r get $key] - - if {$api eq "regular"} { - assert_replication_stream $repl { - {select *} - {set x 1} - {pexpireat x *} - {multi} - {del x} - {lpush before_expired x} - {incr expired} - {exec} - } - } else { - assert_replication_stream $repl { - {select *} - {set expire_x 1} - {pexpireat expire_x *} - {multi} - {del expire_x} - {lpush expired_keys expire_x} - {lpush before_expired expire_x} - {exec} - } - } - close_replication_stream $repl - r DEBUG SET-ACTIVE-EXPIRE 1 - } {OK} {needs:debug} - } - - # Common: an outer post-notification callback's RM_Call accesses an - # already-expired sibling key; the resulting lazy DEL fires KSN, which - # registers another post-notification job from inside the outer - # callback. That inner job must fire from the outer drain, not nested - # inside the outer callback's stack. The regular API gates this via - # execution_nesting; the per-key API gates it via the dedicated - # firing_keyed_post_notif_jobs flag. - foreach {api outer_key inner_key} { - regular read_x x - perkey pkread_expire_target expire_target - } { - test "Test lazy expire inside post job notification ($api API)" { - r flushall - r DEBUG SET-ACTIVE-EXPIRE 0 - set repl [attach_to_replication_stream] - - r set $inner_key 1 - r pexpire $inner_key 1 - after 10 - assert_equal {OK} [r set $outer_key 1] - - if {$api eq "regular"} { - assert_replication_stream $repl { - {select *} - {set x 1} - {pexpireat x *} - {multi} - {set read_x 1} - {del x} - {lpush before_expired x} - {incr expired} - {exec} - } - } else { - assert_replication_stream $repl { - {select *} - {set expire_target 1} - {pexpireat expire_target *} - {multi} - {set pkread_expire_target 1} - {del expire_target} - {lpush expired_keys expire_target} - {lpush before_expired expire_target} - {exec} - } - } - close_replication_stream $repl - r DEBUG SET-ACTIVE-EXPIRE 1 - } {OK} {needs:debug} - } - - # Regular-only: tests REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS, - # which is orthogonal to the post-notification API surface. + # Orthogonal: tests REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS, + # not the post-notification API surface. test {Test nested keyspace notification} { r flushall set repl [attach_to_replication_stream] @@ -257,10 +206,11 @@ tags "modules external:skip" { close_replication_stream $repl } - # Both APIs accept registrations from a NOTIFY_EVICTED handler - # because eviction fires inside the OOM-triggering command's call(), - # so server.executing_client is set. Tested only via the regular - # fixture for brevity — the per-key path is structurally identical. + # Both APIs would accept (eviction fires inside the OOM-triggering + # command's call(), so executing_client is set), but the per-key path + # is structurally identical and only the regular fixture is exercised + # for brevity. Loaded with with_key_events to also assert the + # before_evicted server event. test {Test eviction} { r flushall set repl [attach_to_replication_stream] @@ -283,10 +233,17 @@ tags "modules external:skip" { r config set maxmemory 0 close_replication_stream $repl } {} {needs:config-maxmemory} + } +} + +# Per-key-only tests: behaviors with no regular-API equivalent (the per-key +# API fires at the tail of every call() including each MULTI/EXEC +# sub-command, uses a dedicated reentrance guard, refuses multi-key +# commands, and is the original motivation for HSET+HEXPIRE interleaving). +tags "modules external:skip" { + start_server {} { + r module load $testmodule perkey - # Per-key-only: the per-key callback fires at the tail of EVERY call(), - # including each sub-command inside MULTI/EXEC. The regular API only - # fires at the outermost EXEC. test {Per-key post notification job fires between MULTI/EXEC sub-commands} { r flushall set repl [attach_to_replication_stream] @@ -313,10 +270,6 @@ tags "modules external:skip" { close_replication_stream $repl } - # Per-key-only: the firing function uses its own reentrance guard - # (firing_keyed_post_notif_jobs) because per-key callbacks must fire - # even when execution_nesting > 0. Test that a nested RM_Call inside - # an outer per-key callback does not re-enter the firing function. test {Per-key callback does not re-enter firing while a nested RM_Call is in flight} { r flushall @@ -327,10 +280,6 @@ tags "modules external:skip" { assert_equal {inner_after_outer outer_done} $log } - # Per-key-only: the single-key guard refuses registration when the - # current command touches more than one key. The regular API has no - # such constraint. We also assert that the refusal logs a warning so - # module authors get a hint when they hit this. test {Per-key post notification job is refused on multi-key commands} { r flushall set repl [attach_to_replication_stream] @@ -351,11 +300,6 @@ tags "modules external:skip" { close_replication_stream $repl } - # Per-key-only: HSET and HEXPIRE both touch a single hash, so they - # pass the single-key guard. The per-key callback fires at the tail of - # each sub-command's call(), interleaving an LPUSH between successive - # HSET/HEXPIRE on the same hash inside MULTI/EXEC (the original - # motivation for this API — RED-197766). test {Per-key post notification job fires between HSET and HEXPIRE on the same hash inside MULTI/EXEC} { r flushall set repl [attach_to_replication_stream] @@ -381,7 +325,6 @@ tags "modules external:skip" { } close_replication_stream $repl } - } } @@ -389,7 +332,7 @@ set testmodule2 [file normalize tests/modules/keyspace_events.so] tags "modules external:skip" { start_server {} { - r module load $testmodule with_key_events + r module load $testmodule regular with_key_events r module load $testmodule2 test {Test write on post notification callback} { set repl [attach_to_replication_stream] From 3d4d521eb65a4cba1d8ef4b707d381a797ccec09 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Fri, 22 May 2026 13:59:26 +0200 Subject: [PATCH 14/24] simplify test module --- tests/modules/postnotifications.c | 343 +++++++++++++----------------- 1 file changed, 144 insertions(+), 199 deletions(-) diff --git a/tests/modules/postnotifications.c b/tests/modules/postnotifications.c index ca26f3619..b43b04dd3 100644 --- a/tests/modules/postnotifications.c +++ b/tests/modules/postnotifications.c @@ -1,20 +1,4 @@ -/* This module is used to test the server post-notification keyspace jobs API. - * - * It supports both APIs from a single .so: - * `RedisModule_AddPostNotificationJob` (the "regular" API) - * `RedisModule_AddPostNotificationJobForKey` (the "per-key" API) - * - * The API to register against is chosen via a required load arg: "regular" - * or "perkey". The keyspace handlers use the same key prefixes and produce - * the same post-job side effects in either mode — only the registration - * call differs. This lets the common tests parametrize over the two APIs - * without diverging in keys, asserts, or expected streams. - * - * An optional `with_key_events` arg subscribes to RedisModuleEvent_Key so - * tests can additionally observe `before_deleted`/`before_expired`/ - * `before_evicted`/`before_overwritten` interleaving with the - * post-notification drain. The server-event-driven post-jobs are always - * registered through the regular API (server events are not API-specific). +/* This module is used to test the server post keyspace jobs API. * * ----------------------------------------------------------------------------- * @@ -26,6 +10,13 @@ * GNU Affero General Public License v3 (AGPLv3). */ +/* This module supports both the regular post-notification API + * (RedisModule_AddPostNotificationJob) and the per-key API + * (RedisModule_AddPostNotificationJobForKey). A required load arg — + * "regular" or "perkey" — selects which API the keyspace handlers register + * against. The keyspace handlers and post-job side effects are otherwise + * unchanged: only the registration call differs between modes. */ + #define _BSD_SOURCE #define _DEFAULT_SOURCE /* For usleep */ @@ -35,166 +26,149 @@ #include #include -/* =========================================================================== - * Mode dispatcher: KSN handlers compute (key, target) and forward to one of - * the two APIs based on g_api_mode. The post-job effect is the same in both - * modes; only the registration call differs. - * ======================================================================== */ - -enum api_mode { - MODE_REGULAR, - MODE_PERKEY, -}; +enum api_mode { MODE_REGULAR, MODE_PERKEY }; static int g_api_mode = MODE_REGULAR; -static void FreeHeldString(void *pd) { +static void KeySpace_PostNotificationStringFreePD(void *pd) { RedisModule_FreeString(NULL, pd); } -/* Effects */ - -static void DoIncr(RedisModuleCtx *ctx, RedisModuleString *target) { - RedisModuleCallReply *rep = RedisModule_Call(ctx, "incr", "!s", target); - if (rep) RedisModule_FreeCallReply(rep); +static void KeySpace_PostNotificationReadKey(RedisModuleCtx *ctx, void *pd) { + RedisModuleCallReply* rep = RedisModule_Call(ctx, "get", "!s", pd); + RedisModule_FreeCallReply(rep); } -static void DoGet(RedisModuleCtx *ctx, RedisModuleString *target) { - RedisModuleCallReply *rep = RedisModule_Call(ctx, "get", "!s", target); - if (rep) RedisModule_FreeCallReply(rep); +static void KeySpace_PostNotificationString(RedisModuleCtx *ctx, void *pd) { + REDISMODULE_NOT_USED(ctx); + RedisModuleCallReply* rep = RedisModule_Call(ctx, "incr", "!s", pd); + RedisModule_FreeCallReply(rep); } -/* Regular-API callback wrappers — pd is the target RedisModuleString. */ - -static void RegularJob_Incr(RedisModuleCtx *ctx, void *pd) { - DoIncr(ctx, (RedisModuleString *)pd); -} - -static void RegularJob_Get(RedisModuleCtx *ctx, void *pd) { - DoGet(ctx, (RedisModuleString *)pd); -} - -/* Per-key-API callback wrappers — pd is the target; the key argument is the - * notifying key (used only to satisfy the per-key API's signature). */ - -static void PerKeyJob_Incr(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { +/* Per-key-API trampolines: the per-key API's callback takes an extra `key` + * argument; we ignore it and delegate to the regular-API callback so the + * post-job effect stays identical across modes. */ +static void KeySpace_PostNotificationStringPerKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { REDISMODULE_NOT_USED(key); - DoIncr(ctx, (RedisModuleString *)pd); + KeySpace_PostNotificationString(ctx, pd); } -static void PerKeyJob_Get(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { +static void KeySpace_PostNotificationReadKeyPerKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { REDISMODULE_NOT_USED(key); - DoGet(ctx, (RedisModuleString *)pd); + KeySpace_PostNotificationReadKey(ctx, pd); } -/* Dispatchers — register a post-notification job using the active API. */ - -static int RegisterIncrJob(RedisModuleCtx *ctx, RedisModuleString *key, RedisModuleString *target) { +/* Register a post-notification job through the API selected by g_api_mode. */ +static int RegisterIncrJob(RedisModuleCtx *ctx, RedisModuleString *trigger_key, RedisModuleString *target) { if (g_api_mode == MODE_REGULAR) { - return RedisModule_AddPostNotificationJob(ctx, RegularJob_Incr, target, FreeHeldString); - } - return RedisModule_AddPostNotificationJobForKey(ctx, PerKeyJob_Incr, key, target, FreeHeldString); -} - -static int RegisterGetJob(RedisModuleCtx *ctx, RedisModuleString *key, RedisModuleString *target) { - if (g_api_mode == MODE_REGULAR) { - return RedisModule_AddPostNotificationJob(ctx, RegularJob_Get, target, FreeHeldString); - } - return RedisModule_AddPostNotificationJobForKey(ctx, PerKeyJob_Get, key, target, FreeHeldString); -} - -/* =========================================================================== - * Mode-aware KSN handlers (registered in both modes). - * ======================================================================== */ - -/* "expired" event: register a post-job that INCRs an "expired" counter. */ -static int KeySpace_NotificationExpired(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); - - RedisModuleString *target = RedisModule_CreateString(NULL, "expired", 7); - int res = RegisterIncrJob(ctx, key, target); - if (res == REDISMODULE_ERR) FreeHeldString(target); - return REDISMODULE_OK; -} - -/* "evicted" event: register a post-job that INCRs an "evicted" counter. */ -static int KeySpace_NotificationEvicted(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); - - const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "evicted", 7) == 0) return REDISMODULE_OK; /* skip our sink */ - if (strncmp(key_str, "before_evicted", 14) == 0) return REDISMODULE_OK; /* skip server-event sink */ - - RedisModuleString *target = RedisModule_CreateString(NULL, "evicted", 7); - int res = RegisterIncrJob(ctx, key, target); - if (res == REDISMODULE_ERR) FreeHeldString(target); - return REDISMODULE_OK; -} - -/* "string" event on `string_` keys: register a post-job that INCRs the - * paired `string_changed{}` counter, which itself fires another KSN that - * cascades into INCR `string_total`. */ -static int KeySpace_NotificationString(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { - REDISMODULE_NOT_USED(type); - REDISMODULE_NOT_USED(event); - - const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "string_", 7) != 0) return REDISMODULE_OK; - if (strcmp(key_str, "string_total") == 0) return REDISMODULE_OK; - - RedisModuleString *target; - if (strncmp(key_str, "string_changed{", 15) == 0) { - target = RedisModule_CreateString(NULL, "string_total", 12); + return RedisModule_AddPostNotificationJob(ctx, KeySpace_PostNotificationString, target, KeySpace_PostNotificationStringFreePD); } else { - target = RedisModule_CreateStringPrintf(NULL, "string_changed{%s}", key_str); + return RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationStringPerKey, trigger_key, target, KeySpace_PostNotificationStringFreePD); + } +} + +static int RegisterGetJob(RedisModuleCtx *ctx, RedisModuleString *trigger_key, RedisModuleString *target) { + if (g_api_mode == MODE_REGULAR) { + return RedisModule_AddPostNotificationJob(ctx, KeySpace_PostNotificationReadKey, target, KeySpace_PostNotificationStringFreePD); + } else { + return RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationReadKeyPerKey, trigger_key, target, KeySpace_PostNotificationStringFreePD); + } +} + +static int KeySpace_NotificationExpired(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + RedisModuleString *new_key = RedisModule_CreateString(NULL, "expired", 7); + int res = RegisterIncrJob(ctx, key, new_key); + if (res == REDISMODULE_ERR) KeySpace_PostNotificationStringFreePD(new_key); + return REDISMODULE_OK; +} + +static int KeySpace_NotificationEvicted(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + REDISMODULE_NOT_USED(key); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + + if (strncmp(key_str, "evicted", 7) == 0) { + return REDISMODULE_OK; /* do not count the evicted key */ } - int res = RegisterIncrJob(ctx, key, target); - if (res == REDISMODULE_ERR) FreeHeldString(target); + if (strncmp(key_str, "before_evicted", 14) == 0) { + return REDISMODULE_OK; /* do not count the before_evicted key */ + } + + RedisModuleString *new_key = RedisModule_CreateString(NULL, "evicted", 7); + int res = RegisterIncrJob(ctx, key, new_key); + if (res == REDISMODULE_ERR) KeySpace_PostNotificationStringFreePD(new_key); return REDISMODULE_OK; } -/* "string" event on `read_` keys: register a post-job that GETs `` — - * used to drive lazy expire on `` from inside a post-notification - * callback. */ -static int KeySpace_LazyExpireInsidePostNotificationJob(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { +static int KeySpace_NotificationString(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ + REDISMODULE_NOT_USED(ctx); REDISMODULE_NOT_USED(type); REDISMODULE_NOT_USED(event); const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "read_", 5) != 0) return REDISMODULE_OK; - RedisModuleString *target = RedisModule_CreateString(NULL, key_str + 5, strlen(key_str) - 5); - int res = RegisterGetJob(ctx, key, target); - if (res == REDISMODULE_ERR) FreeHeldString(target); + if (strncmp(key_str, "string_", 7) != 0) { + return REDISMODULE_OK; + } + + if (strcmp(key_str, "string_total") == 0) { + return REDISMODULE_OK; + } + + RedisModuleString *new_key; + if (strncmp(key_str, "string_changed{", 15) == 0) { + new_key = RedisModule_CreateString(NULL, "string_total", 12); + } else { + new_key = RedisModule_CreateStringPrintf(NULL, "string_changed{%s}", key_str); + } + + int res = RegisterIncrJob(ctx, key, new_key); + if (res == REDISMODULE_ERR) KeySpace_PostNotificationStringFreePD(new_key); return REDISMODULE_OK; } -/* "string" event on `write_sync_` keys: directly RM_Call SET 1 from - * inside the handler (no post-job). Exercises - * REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS, which is - * orthogonal to the post-notification APIs. */ -static int KeySpace_NestedNotification(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { +static int KeySpace_LazyExpireInsidePostNotificationJob(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ + REDISMODULE_NOT_USED(ctx); REDISMODULE_NOT_USED(type); REDISMODULE_NOT_USED(event); const char *key_str = RedisModule_StringPtrLen(key, NULL); - if (strncmp(key_str, "write_sync_", 11) != 0) return REDISMODULE_OK; + + if (strncmp(key_str, "read_", 5) != 0) { + return REDISMODULE_OK; + } + + RedisModuleString *new_key = RedisModule_CreateString(NULL, key_str + 5, strlen(key_str) - 5);; + int res = RegisterGetJob(ctx, key, new_key); + if (res == REDISMODULE_ERR) KeySpace_PostNotificationStringFreePD(new_key); + return REDISMODULE_OK; +} + +static int KeySpace_NestedNotification(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key){ + REDISMODULE_NOT_USED(ctx); + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + + if (strncmp(key_str, "write_sync_", 11) != 0) { + return REDISMODULE_OK; + } /* This test was only meant to check REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS. - * In general it is wrong and discouraged to perform any writes inside a notification callback. */ - RedisModuleString *new_key = RedisModule_CreateString(NULL, key_str + 11, strlen(key_str) - 11); - RedisModuleCallReply *rep = RedisModule_Call(ctx, "set", "!sc", new_key, "1"); + * In general it is wrong and discourage to perform any writes inside a notification callback. */ + RedisModuleString *new_key = RedisModule_CreateString(NULL, key_str + 11, strlen(key_str) - 11);; + RedisModuleCallReply* rep = RedisModule_Call(ctx, "set", "!sc", new_key, "1"); RedisModule_FreeCallReply(rep); RedisModule_FreeString(NULL, new_key); return REDISMODULE_OK; } -/* =========================================================================== - * Async write from a module thread (mode-independent). - * ======================================================================== */ - typedef struct AsyncSetArgs { RedisModuleBlockedClient *bc; RedisModuleString *key; @@ -204,7 +178,7 @@ static void *KeySpace_PostNotificationsAsyncSetInner(void *arg) { AsyncSetArgs *args = arg; RedisModuleCtx *ctx = RedisModule_GetThreadSafeContext(args->bc); RedisModule_ThreadSafeContextLock(ctx); - RedisModuleCallReply *rep = RedisModule_Call(ctx, "set", "!sc", args->key, "1"); + RedisModuleCallReply* rep = RedisModule_Call(ctx, "set", "!sc", args->key, "1"); RedisModule_ThreadSafeContextUnlock(ctx); RedisModule_ReplyWithCallReply(ctx, rep); RedisModule_FreeCallReply(rep); @@ -221,26 +195,20 @@ static int KeySpace_PostNotificationsAsyncSet(RedisModuleCtx *ctx, RedisModuleSt return RedisModule_WrongArity(ctx); AsyncSetArgs *args = RedisModule_Alloc(sizeof(*args)); - args->bc = RedisModule_BlockClient(ctx, NULL, NULL, NULL, 0); + args->bc = RedisModule_BlockClient(ctx,NULL,NULL,NULL,0); args->key = RedisModule_HoldString(NULL, argv[1]); pthread_t tid; - if (pthread_create(&tid, NULL, KeySpace_PostNotificationsAsyncSetInner, args) != 0) { + if (pthread_create(&tid,NULL,KeySpace_PostNotificationsAsyncSetInner,args) != 0) { RedisModule_AbortBlock(args->bc); RedisModule_FreeString(NULL, args->key); RedisModule_Free(args); - return RedisModule_ReplyWithError(ctx, "-ERR Can't start thread"); + return RedisModule_ReplyWithError(ctx,"-ERR Can't start thread"); } pthread_detach(tid); return REDISMODULE_OK; } -/* =========================================================================== - * Server-event handler: subscribes to RedisModuleEvent_Key when - * `with_key_events` is passed at load time. Always uses the regular API — - * server events are not API-specific. - * ======================================================================== */ - typedef struct KeySpace_EventPostNotificationCtx { RedisModuleString *triggered_on; RedisModuleString *new_key; @@ -256,7 +224,7 @@ static void KeySpace_ServerEventPostNotificationFree(void *pd) { static void KeySpace_ServerEventPostNotification(RedisModuleCtx *ctx, void *pd) { REDISMODULE_NOT_USED(ctx); KeySpace_EventPostNotificationCtx *pn_ctx = pd; - RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!ss", pn_ctx->new_key, pn_ctx->triggered_on); + RedisModuleCallReply* rep = RedisModule_Call(ctx, "lpush", "!ss", pn_ctx->new_key, pn_ctx->triggered_on); RedisModule_FreeCallReply(rep); } @@ -267,7 +235,7 @@ static void KeySpace_ServerEventCallback(RedisModuleCtx *ctx, RedisModuleEvent e RedisModule_Log(ctx, "warning", "Got an unexpected subevent '%llu'", (unsigned long long)subevent); return; } - static const char *events[] = { + static const char* events[] = { "before_deleted", "before_expired", "before_evicted", @@ -277,9 +245,9 @@ static void KeySpace_ServerEventCallback(RedisModuleCtx *ctx, RedisModuleEvent e const RedisModuleString *key_name = RedisModule_GetKeyNameFromModuleKey(((RedisModuleKeyInfo*)data)->key); const char *key_str = RedisModule_StringPtrLen(key_name, NULL); - for (int i = 0; i < 4; ++i) { + for (int i = 0 ; i < 4 ; ++i) { const char *event = events[i]; - if (strncmp(key_str, event, strlen(event)) == 0) { + if (strncmp(key_str, event , strlen(event)) == 0) { return; /* don't log any event on our tracking keys */ } } @@ -291,15 +259,9 @@ static void KeySpace_ServerEventCallback(RedisModuleCtx *ctx, RedisModuleEvent e if (res == REDISMODULE_ERR) KeySpace_ServerEventPostNotificationFree(pn_ctx); } -/* =========================================================================== - * Per-key-only fixtures: behaviors that don't exist on the regular API - * (firing inside MULTI/EXEC, reentrance guard, multi-key refusal, hash - * subkey interleaving). Registered only in MODE_PERKEY. - * ======================================================================== */ +/* Per-key-only fixtures: behaviors with no regular-API equivalent. Registered + * only when the module is loaded in "perkey" mode. */ -/* "batched_" path: each keyed callback appends the touched key to a sink - * list. Used to assert per-sub-command firing inside MULTI/EXEC and the - * single-key guard on multi-key commands. */ static void KeySpace_PostNotificationBatchedKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { REDISMODULE_NOT_USED(pd); RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "batched_keys", key); @@ -318,9 +280,6 @@ static int KeySpace_NotificationBatched(RedisModuleCtx *ctx, int type, const cha return REDISMODULE_OK; } -/* "hash_" path: HSET/HEXPIRE both fire NOTIFY_HASH against a single hash - * key, so they pass the single-key guard. Used to assert per-sub-command - * firing between HSET and HEXPIRE on the same hash inside MULTI/EXEC. */ static void KeySpace_PostNotificationHashKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { REDISMODULE_NOT_USED(pd); RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "hash_keys", key); @@ -338,8 +297,6 @@ static int KeySpace_NotificationHash(RedisModuleCtx *ctx, int type, const char * return REDISMODULE_OK; } -/* "reentrant_" path: probes that the firing function does not re-enter - * while a nested RM_Call is in flight. */ static int reentrance_in_outer_callback = 0; static void KeySpace_PostNotificationReentranceProbe(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { @@ -372,14 +329,10 @@ static int KeySpace_NotificationReentrance(RedisModuleCtx *ctx, int type, const return REDISMODULE_OK; } -/* =========================================================================== - * OnLoad: parse args, set mode, subscribe handlers. - * Required arg: "regular" | "perkey". - * Optional arg: "with_key_events". - * ======================================================================== */ - +/* This function must be present on each Redis module. It is used in order to + * register the commands into the Redis server. */ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { - if (RedisModule_Init(ctx, "postnotifications", 1, REDISMODULE_APIVER_1) == REDISMODULE_ERR) { + if (RedisModule_Init(ctx,"postnotifications",1,REDISMODULE_APIVER_1) == REDISMODULE_ERR){ return REDISMODULE_ERR; } @@ -387,47 +340,46 @@ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) return REDISMODULE_ERR; } - int mode_set = 0; int with_key_events = 0; for (int i = 0; i < argc; i++) { - const char *arg = RedisModule_StringPtrLen(argv[i], NULL); - if (strcmp(arg, "regular") == 0) { - g_api_mode = MODE_REGULAR; - mode_set = 1; + const char *arg = RedisModule_StringPtrLen(argv[i], 0); + if (strcmp(arg, "with_key_events") == 0) { + with_key_events = 1; } else if (strcmp(arg, "perkey") == 0) { g_api_mode = MODE_PERKEY; - mode_set = 1; - } else if (strcmp(arg, "with_key_events") == 0) { - with_key_events = 1; - } else { - RedisModule_Log(ctx, "warning", "Unknown load arg '%s'", arg); - return REDISMODULE_ERR; + } else if (strcmp(arg, "regular") == 0) { + g_api_mode = MODE_REGULAR; } } - if (!mode_set) { - RedisModule_Log(ctx, "warning", "postnotifications module requires a mode arg ('regular' or 'perkey')."); - return REDISMODULE_ERR; - } RedisModule_SetModuleOptions(ctx, REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS); - /* Mode-aware KSN handlers — registered in both modes, dispatch internally. */ - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationString) != REDISMODULE_OK) { + if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationString) != REDISMODULE_OK){ return REDISMODULE_ERR; } - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_LazyExpireInsidePostNotificationJob) != REDISMODULE_OK) { + + if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_LazyExpireInsidePostNotificationJob) != REDISMODULE_OK){ return REDISMODULE_ERR; } - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NestedNotification) != REDISMODULE_OK) { + + if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NestedNotification) != REDISMODULE_OK){ return REDISMODULE_ERR; } - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_EXPIRED, KeySpace_NotificationExpired) != REDISMODULE_OK) { + + if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_EXPIRED, KeySpace_NotificationExpired) != REDISMODULE_OK){ return REDISMODULE_ERR; } - if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_EVICTED, KeySpace_NotificationEvicted) != REDISMODULE_OK) { + + if(RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_EVICTED, KeySpace_NotificationEvicted) != REDISMODULE_OK){ return REDISMODULE_ERR; } + if (with_key_events) { + if(RedisModule_SubscribeToServerEvent(ctx, RedisModuleEvent_Key, KeySpace_ServerEventCallback) != REDISMODULE_OK){ + return REDISMODULE_ERR; + } + } + /* Per-key-only fixtures (behaviors with no regular API equivalent). */ if (g_api_mode == MODE_PERKEY) { if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationBatched) != REDISMODULE_OK) { @@ -441,15 +393,8 @@ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) } } - /* Optional server-event subscription. Always registers regular post-jobs. */ - if (with_key_events) { - if (RedisModule_SubscribeToServerEvent(ctx, RedisModuleEvent_Key, KeySpace_ServerEventCallback) != REDISMODULE_OK) { - return REDISMODULE_ERR; - } - } - if (RedisModule_CreateCommand(ctx, "postnotification.async_set", KeySpace_PostNotificationsAsyncSet, - "write", 1, 1, 1) == REDISMODULE_ERR) { + "write", 1, 1, 1) == REDISMODULE_ERR){ return REDISMODULE_ERR; } From f04dd8cca80c08e58e9e17198cd3335ad65eea4d Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Fri, 22 May 2026 14:20:41 +0200 Subject: [PATCH 15/24] improve testing order to ease review --- tests/unit/moduleapi/postnotifications.tcl | 162 ++++++++------------- 1 file changed, 61 insertions(+), 101 deletions(-) diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index ed08aad42..e302d4036 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -1,27 +1,11 @@ set testmodule [file normalize tests/modules/postnotifications.so] -# ---------------------------------------------------------------------------- -# The test module accepts a "regular" or "perkey" load arg that selects which -# post-notification API its handlers register against. The handlers' key -# prefixes and post-job side effects are identical in either mode — only the -# RM_AddPostNotificationJob vs RM_AddPostNotificationJobForKey call differs. -# That lets the common tests use identical keys, asserts, and expected -# replication streams for both APIs by wrapping the start_server itself in a -# foreach. -# -# `with_key_events` is loaded *only* by the regular-only block, since the -# server-event LPUSHes (`before_*`) ride the regular drain in both modes but -# their position differs vs. the per-key drain. Server-event interleaving is -# tested explicitly in that block. -# ---------------------------------------------------------------------------- - foreach api {regular perkey} { tags "modules external:skip" { start_server {} { r module load $testmodule $api test "Test write on post notification callback ($api API)" { - r flushall set repl [attach_to_replication_stream] r set string_x 1 @@ -47,6 +31,14 @@ foreach api {regular perkey} { } close_replication_stream $repl } + } + } +} + +foreach api {regular perkey} { + tags "modules external:skip" { + start_server {} { + r module load $testmodule $api test "Test write on post notification callback from module thread ($api API)" { r flushall @@ -66,6 +58,48 @@ foreach api {regular perkey} { } close_replication_stream $repl } + } + } +} + +tags "modules external:skip" { + start_server {} { + r module load $testmodule with_key_events + + test {Test active expire} { + r flushall + set repl [attach_to_replication_stream] + + r set x 1 + r pexpire x 10 + + wait_for_condition 100 50 { + [r keys expired] == {expired} + } else { + puts [r keys *] + fail "Failed waiting for x to expired" + } + + # the {lpush before_expired x} is a post notification job registered before 'x' got expired + assert_replication_stream $repl { + {select *} + {set x 1} + {pexpireat x *} + {multi} + {del x} + {lpush before_expired x} + {incr expired} + {exec} + } + close_replication_stream $repl + } + } +} + +foreach api {regular perkey} { + tags "modules external:skip" { + start_server {} { + r module load $testmodule $api test "Test lazy expire ($api API)" { r flushall @@ -89,6 +123,14 @@ foreach api {regular perkey} { close_replication_stream $repl r DEBUG SET-ACTIVE-EXPIRE 1 } {OK} {needs:debug} + } + } +} + +foreach api {regular perkey} { + tags "modules external:skip" { + start_server {} { + r module load $testmodule $api test "Test lazy expire inside post job notification ($api API)" { r flushall @@ -117,78 +159,10 @@ foreach api {regular perkey} { } } -# Regular-only tests: behaviors that depend on the regular API specifically -# (server events register via the regular API), or that the per-key API -# refuses outright (active expire fires from cron with executing_client=NULL), -# or that are orthogonal to the post-notification API (nested KSN). tags "modules external:skip" { start_server {} { - r module load $testmodule regular with_key_events + r module load $testmodule with_key_events - test {Server event before_overwritten interleaves with the KSN-driven post-notification job} { - r flushall - set repl [attach_to_replication_stream] - - # The first SET creates string_x. The second SET overwrites it, - # which fires the RedisModuleEvent_Key.before_overwritten server - # event. That server event registers a post-job (via the regular - # API) that LPUSHes string_x into `before_overwritten`. Both side - # effects ride the regular drain — the server-event LPUSH was - # registered first (inside dbGenericDelete, before - # notifyKeyspaceEvent), so it appears before the KSN-driven INCRs. - r set string_x 1 - r set string_x 2 - - assert_replication_stream $repl { - {multi} - {select *} - {set string_x 1} - {incr string_changed{string_x}} - {incr string_total} - {exec} - {multi} - {set string_x 2} - {lpush before_overwritten string_x} - {incr string_changed{string_x}} - {incr string_total} - {exec} - } - close_replication_stream $repl - } - - # True API divergence: active expire fires from cron with - # server.executing_client == NULL, so the per-key API's single-key - # guard refuses the registration. Regular-only by construction. - test {Test active expire} { - r flushall - set repl [attach_to_replication_stream] - - r set x 1 - r pexpire x 10 - - wait_for_condition 100 50 { - [r keys expired] == {expired} - } else { - puts [r keys *] - fail "Failed waiting for x to expired" - } - - # the {lpush before_expired x} is a post notification job registered before 'x' got expired - assert_replication_stream $repl { - {select *} - {set x 1} - {pexpireat x *} - {multi} - {del x} - {lpush before_expired x} - {incr expired} - {exec} - } - close_replication_stream $repl - } - - # Orthogonal: tests REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS, - # not the post-notification API surface. test {Test nested keyspace notification} { r flushall set repl [attach_to_replication_stream] @@ -206,11 +180,6 @@ tags "modules external:skip" { close_replication_stream $repl } - # Both APIs would accept (eviction fires inside the OOM-triggering - # command's call(), so executing_client is set), but the per-key path - # is structurally identical and only the regular fixture is exercised - # for brevity. Loaded with with_key_events to also assert the - # before_evicted server event. test {Test eviction} { r flushall set repl [attach_to_replication_stream] @@ -230,16 +199,12 @@ tags "modules external:skip" { {incr evicted} {exec} } - r config set maxmemory 0 close_replication_stream $repl } {} {needs:config-maxmemory} } } -# Per-key-only tests: behaviors with no regular-API equivalent (the per-key -# API fires at the tail of every call() including each MULTI/EXEC -# sub-command, uses a dedicated reentrance guard, refuses multi-key -# commands, and is the original motivation for HSET+HEXPIRE interleaving). +# Per-key-only tests (no regular-API equivalent). tags "modules external:skip" { start_server {} { r module load $testmodule perkey @@ -332,7 +297,7 @@ set testmodule2 [file normalize tests/modules/keyspace_events.so] tags "modules external:skip" { start_server {} { - r module load $testmodule regular with_key_events + r module load $testmodule with_key_events r module load $testmodule2 test {Test write on post notification callback} { set repl [attach_to_replication_stream] @@ -363,11 +328,6 @@ tags "modules external:skip" { {incr string_changed{string_x}} {incr string_total} {exec} - {multi} - {set string1_x 1} - {incr string_changed{string1_x}} - {incr string_total} - {exec} } close_replication_stream $repl } From ac959655ceb3e7ded9d870f9626465358ff4b4e0 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Fri, 22 May 2026 17:27:31 +0200 Subject: [PATCH 16/24] add fixture in test eviction showing slight discrepancy --- tests/unit/moduleapi/postnotifications.tcl | 69 ++++++++++++++++------ 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index e302d4036..47eccdccb 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -179,28 +179,59 @@ tags "modules external:skip" { } close_replication_stream $repl } + } +} - test {Test eviction} { - r flushall - set repl [attach_to_replication_stream] - r set x 1 - r config set maxmemory-policy allkeys-random - r config set maxmemory 1 +foreach api {regular perkey} { + tags "modules external:skip" { + start_server {} { + r module load $testmodule $api with_key_events - assert_error {OOM *} {r set y 1} + test "Test eviction ($api API)" { + r flushall + set repl [attach_to_replication_stream] + r set x 1 + r config set maxmemory-policy allkeys-random + r config set maxmemory 1 - # the {lpush before_evicted x} is a post notification job registered before 'x' got evicted - assert_replication_stream $repl { - {select *} - {set x 1} - {multi} - {del x} - {lpush before_evicted x} - {incr evicted} - {exec} - } - close_replication_stream $repl - } {} {needs:config-maxmemory} + assert_error {OOM *} {r set y 1} + + # the {lpush before_evicted x} is a post notification job + # registered before 'x' got evicted via the RedisModuleEvent_Key + # server event (always uses the regular post-notif queue). + # + # Under the per-key API the keyspace-notification side-effect + # ({incr evicted}) drains from firePostKeyedNotificationJobs at + # the top of afterCommand, before the regular post-notif queue + # drains and before propagatePendingCommands flushes the outer + # multi/exec. With maxmemory=1 still in force, that per-key + # call() hits OOM and does not propagate; the trailing + # {del before_evicted} is the eviction of the list created by + # the regular drain's lpush. + if {$api eq "perkey"} { + assert_replication_stream $repl { + {select *} + {set x 1} + {multi} + {del x} + {lpush before_evicted x} + {exec} + {del before_evicted} + } + } else { + assert_replication_stream $repl { + {select *} + {set x 1} + {multi} + {del x} + {lpush before_evicted x} + {incr evicted} + {exec} + } + } + close_replication_stream $repl + } {} {needs:config-maxmemory} + } } } From 8b2230ad65adc50e3b24b79b6668448c59aec529 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Fri, 22 May 2026 17:52:08 +0200 Subject: [PATCH 17/24] fix to handle in notification handler --- src/module.c | 56 ++++---- src/server.c | 8 ++ src/server.h | 4 + tests/modules/postnotifications.c | 20 +++ tests/unit/moduleapi/postnotifications.tcl | 141 ++++++++++++++------- 5 files changed, 156 insertions(+), 73 deletions(-) diff --git a/src/module.c b/src/module.c index 50ba73350..7faaad12c 100644 --- a/src/module.c +++ b/src/module.c @@ -9522,42 +9522,39 @@ int RM_AddPostNotificationJob(RedisModuleCtx *ctx, RedisModulePostNotificationJo return REDISMODULE_OK; } -/* Sibling of `RM_AddPostNotificationJob` that fires per-key. The callback is - * invoked at the tail of the currently executing command (and at the tail of - * every sub-command inside MULTI/EXEC), so a module can observe per-key - * effects before the next sub-command runs. Jobs fire in submission order. +/* Sibling of `RM_AddPostNotificationJob` that fires per-key. May only be + * called from within a keyspace-notification handler (the single-key context + * the API is scoped to). Each call binds the job to the caller-supplied + * `key`, so multi-key commands such as MSET (which emit one notification per + * key) may register one job per affected key. * - * Only commands that touch exactly one key are supported. If called while the - * current command touches zero or more than one key, the registration is - * refused with REDISMODULE_ERR. This avoids the ambiguity of "per-key" firing - * inside multi-key commands (MSET, SUNIONSTORE, ...). + * Firing schedule (superset of `RM_AddPostNotificationJob`): + * - At the tail of every `call()`, so per-key effects are observable between + * MULTI/EXEC sub-commands. + * - At the end of the outermost execution unit, alongside the regular + * post-notification jobs. This covers the active-expire and eviction + * paths (both call `postExecutionUnitOperations` themselves). * - * `key` must be a valid RedisModuleString. The implementation takes its own - * reference; the caller retains ownership of its own reference and may free - * it at any time. `free_pd` may be NULL. + * Jobs fire in submission order. `key` must be a valid RedisModuleString; the + * implementation takes its own reference and the caller retains ownership of + * its own reference. `free_pd` may be NULL. * - * Return REDISMODULE_OK on success and REDISMODULE_ERR if called while loading - * data from disk (AOF or RDB), on a read-only replica, or if the currently - * executing command does not touch exactly one key. */ + * Return REDISMODULE_OK on success and REDISMODULE_ERR if called while + * loading data from disk (AOF or RDB), on a read-only replica, or outside a + * keyspace-notification handler. */ int RM_AddPostNotificationJobForKey(RedisModuleCtx *ctx, RedisModulePostNotificationJobPerKeyFunc callback, RedisModuleString *key, void *privdata, void (*free_privdata)(void*)) { if (server.loading || (server.masterhost && server.repl_slave_ro)) { return REDISMODULE_ERR; } - /* Restrict to single-key commands. */ - client *ec = server.executing_client; - if (!ec || !ec->cmd) return REDISMODULE_ERR; - getKeysResult result = GETKEYS_RESULT_INIT; - getKeysFromCommand(ec->cmd, ec->argv, ec->argc, &result); - int numkeys = result.numkeys; - getKeysFreeResult(&result); - if (numkeys != 1) { + /* The API is only meaningful from inside a keyspace-notification handler: + * that is the single-key context the per-key contract is scoped to. */ + if (!server.in_keyspace_notification) { serverLog(LL_WARNING, "API misuse detected in module %s: " - "RedisModule_AddPostNotificationJobForKey called from a notification " - "on command '%s' which touches %d keys; the per-key API requires " - "exactly one key.", - ctx->module->name, ec->cmd->fullname, numkeys); + "RedisModule_AddPostNotificationJobForKey called outside a " + "keyspace-notification handler.", + ctx->module->name); return REDISMODULE_ERR; } @@ -9632,6 +9629,12 @@ void moduleNotifyKeyspaceEvent(int type, const char *event, robj *key, int dbid, * but we do not want to update the cached time */ enterExecutionUnit(0, 0); + /* Mark that we are inside a keyspace-notification dispatch. This is the + * single-key context that RM_AddPostNotificationJobForKey requires; the + * counter form lets nested notifications (a callback that triggers + * another KSN) nest cleanly. */ + server.in_keyspace_notification++; + listIter li; listNode *ln; listRewind(moduleKeyspaceSubscribers,&li); @@ -9679,6 +9682,7 @@ void moduleNotifyKeyspaceEvent(int type, const char *event, robj *key, int dbid, } } + server.in_keyspace_notification--; exitExecutionUnit(); } diff --git a/src/server.c b/src/server.c index fd9550084..9d1420d7e 100644 --- a/src/server.c +++ b/src/server.c @@ -2973,6 +2973,7 @@ void initServer(void) { server.errors_enabled = 1; server.execution_nesting = 0; server.firing_keyed_post_notif_jobs = 0; + server.in_keyspace_notification = 0; server.clients = listCreate(); server.clients_index = raxNew(); server.clients_to_close = listCreate(); @@ -3848,6 +3849,13 @@ void postExecutionUnitOperations(void) { if (server.execution_nesting) return; + /* Drain keyed post-notification jobs before the regular ones. This is the + * exit-execution-unit firing site that mirrors afterCommand's drain + * order, and it is the point at which active-expire (expire.c) and + * eviction (evict.c) get the per-key callback delivered — both call us + * directly after their own enter/exitExecutionUnit. */ + firePostKeyedNotificationJobs(); + firePostExecutionUnitJobs(); /* If we are at the top-most call() and not inside a an active module diff --git a/src/server.h b/src/server.h index a466c3fc8..c1cb4295e 100644 --- a/src/server.h +++ b/src/server.h @@ -2064,6 +2064,10 @@ struct redisServer { * cron stuff (active expire, eviction) */ int firing_keyed_post_notif_jobs; /* Re-entrance guard while * firePostKeyedNotificationJobs is draining. */ + int in_keyspace_notification; /* >0 while inside a moduleNotifyKeyspaceEvent + * dispatch. Defines the scope from which + * RM_AddPostNotificationJobForKey may be called; + * a counter so nested notifications nest cleanly. */ rax *clients_index; /* Active clients dictionary by client ID. */ uint32_t paused_actions; /* Bitmask of actions that are currently paused */ list *postponed_clients; /* List of postponed clients */ diff --git a/tests/modules/postnotifications.c b/tests/modules/postnotifications.c index b43b04dd3..bf7a7205f 100644 --- a/tests/modules/postnotifications.c +++ b/tests/modules/postnotifications.c @@ -329,6 +329,23 @@ static int KeySpace_NotificationReentrance(RedisModuleCtx *ctx, int type, const return REDISMODULE_OK; } +static void KeySpace_PostNotificationMissKey(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + RedisModuleCallReply *rep = RedisModule_Call(ctx, "lpush", "!cs", "mget_misses", key); + if (rep) RedisModule_FreeCallReply(rep); +} + +static int KeySpace_NotificationMiss(RedisModuleCtx *ctx, int type, const char *event, RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + + const char *key_str = RedisModule_StringPtrLen(key, NULL); + if (strncmp(key_str, "miss_", 5) != 0) return REDISMODULE_OK; + + RedisModule_AddPostNotificationJobForKey(ctx, KeySpace_PostNotificationMissKey, key, NULL, NULL); + return REDISMODULE_OK; +} + /* This function must be present on each Redis module. It is used in order to * register the commands into the Redis server. */ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { @@ -391,6 +408,9 @@ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_STRING, KeySpace_NotificationReentrance) != REDISMODULE_OK) { return REDISMODULE_ERR; } + if (RedisModule_SubscribeToKeyspaceEvents(ctx, REDISMODULE_NOTIFY_KEY_MISS, KeySpace_NotificationMiss) != REDISMODULE_OK) { + return REDISMODULE_ERR; + } } if (RedisModule_CreateCommand(ctx, "postnotification.async_set", KeySpace_PostNotificationsAsyncSet, diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index 47eccdccb..66aaf4420 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -62,36 +62,61 @@ foreach api {regular perkey} { } } -tags "modules external:skip" { - start_server {} { - r module load $testmodule with_key_events +foreach api {regular perkey} { + tags "modules external:skip" { + start_server {} { + r module load $testmodule $api with_key_events - test {Test active expire} { - r flushall - set repl [attach_to_replication_stream] + test "Test active expire ($api API)" { + r flushall + set repl [attach_to_replication_stream] - r set x 1 - r pexpire x 10 + r set x 1 + r pexpire x 10 - wait_for_condition 100 50 { - [r keys expired] == {expired} - } else { - puts [r keys *] - fail "Failed waiting for x to expired" + wait_for_condition 100 50 { + [r keys expired] == {expired} + } else { + puts [r keys *] + fail "Failed waiting for x to expired" + } + + # {lpush before_expired x} is a post-notification job registered + # via the RedisModuleEvent_Key/before_expired server event, which + # always uses the regular post-notif queue. {incr expired} is the + # keyspace-handler side-effect — registered on the regular queue + # in regular mode and on the per-key queue in perkey mode. + # + # Both APIs drain at postExecutionUnitOperations (called from + # activeExpireCycleTryExpire). In regular mode both jobs share + # the regular queue and fire in registration order. In perkey + # mode the per-key queue is drained before the regular queue, + # so {incr expired} propagates first. + if {$api eq "perkey"} { + assert_replication_stream $repl { + {select *} + {set x 1} + {pexpireat x *} + {multi} + {del x} + {incr expired} + {lpush before_expired x} + {exec} + } + } else { + assert_replication_stream $repl { + {select *} + {set x 1} + {pexpireat x *} + {multi} + {del x} + {lpush before_expired x} + {incr expired} + {exec} + } + } + close_replication_stream $repl } - - # the {lpush before_expired x} is a post notification job registered before 'x' got expired - assert_replication_stream $repl { - {select *} - {set x 1} - {pexpireat x *} - {multi} - {del x} - {lpush before_expired x} - {incr expired} - {exec} - } - close_replication_stream $repl } } } @@ -196,27 +221,23 @@ foreach api {regular perkey} { assert_error {OOM *} {r set y 1} - # the {lpush before_evicted x} is a post notification job - # registered before 'x' got evicted via the RedisModuleEvent_Key - # server event (always uses the regular post-notif queue). - # - # Under the per-key API the keyspace-notification side-effect - # ({incr evicted}) drains from firePostKeyedNotificationJobs at - # the top of afterCommand, before the regular post-notif queue - # drains and before propagatePendingCommands flushes the outer - # multi/exec. With maxmemory=1 still in force, that per-key - # call() hits OOM and does not propagate; the trailing - # {del before_evicted} is the eviction of the list created by - # the regular drain's lpush. + # {lpush before_evicted x} is registered via the + # RedisModuleEvent_Key/before_evicted server event (always uses + # the regular post-notif queue). {incr evicted} comes from the + # keyspace handler — on the regular queue in regular mode and + # on the per-key queue in perkey mode. Both APIs drain in + # postExecutionUnitOperations (called from performEvictions); + # in perkey mode the per-key queue is drained first, so + # {incr evicted} propagates before {lpush before_evicted x}. if {$api eq "perkey"} { assert_replication_stream $repl { {select *} {set x 1} {multi} {del x} + {incr evicted} {lpush before_evicted x} {exec} - {del before_evicted} } } else { assert_replication_stream $repl { @@ -276,22 +297,48 @@ tags "modules external:skip" { assert_equal {inner_after_outer outer_done} $log } - test {Per-key post notification job is refused on multi-key commands} { + test {Per-key post notification job fires per affected key on multi-key commands} { r flushall set repl [attach_to_replication_stream] - set baseline [count_log_message 0 "AddPostNotificationJobForKey"] + # MSET emits one notifyKeyspaceEvent per key. Each dispatch sets + # server.in_keyspace_notification, so the keyspace handler can + # register one per-key job per affected key. All three jobs fire + # at the tail of MSET's call() and propagate inside the same + # multi/exec the propagation buffer flushes. r mset batched_a 1 batched_b 2 batched_c 3 - assert_equal {} [r lrange batched_keys 0 -1] - - # MSET touches 3 keys; the keyspace handler fires once per key, so - # the warning is emitted three times. - set after [count_log_message 0 "AddPostNotificationJobForKey"] - assert_equal 3 [expr {$after - $baseline}] + assert_equal {batched_c batched_b batched_a} [r lrange batched_keys 0 -1] assert_replication_stream $repl { + {multi} {select *} {mset batched_a 1 batched_b 2 batched_c 3} + {lpush batched_keys batched_a} + {lpush batched_keys batched_b} + {lpush batched_keys batched_c} + {exec} + } + close_replication_stream $repl + } + + test {Per-key post notification job fires per missing key on MGET (multi-key read)} { + r flushall + set repl [attach_to_replication_stream] + + # MGET emits one NOTIFY_KEY_MISS notification per missing key. Each + # dispatch sets server.in_keyspace_notification, so the per-key + # handler registers one job per miss. The jobs drain at the tail of + # MGET's call(), propagating as a multi/exec after the read. + assert_equal {{} {} {}} [r mget miss_a miss_b miss_c] + assert_equal {miss_c miss_b miss_a} [r lrange mget_misses 0 -1] + + assert_replication_stream $repl { + {multi} + {select *} + {lpush mget_misses miss_a} + {lpush mget_misses miss_b} + {lpush mget_misses miss_c} + {exec} } close_replication_stream $repl } From f1bc4fff025b04aa4a825a2c91845c33ae805b76 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Fri, 22 May 2026 17:58:38 +0200 Subject: [PATCH 18/24] drain regular before per-key at the shared firing site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside postExecutionUnitOperations, drain regular post-notification jobs before per-key ones (was the reverse). At afterCommand the per-key drain still runs first — that's what gives the per-sub-command ordering inside MULTI/EXEC — but at the exit-execution-unit site (which active-expire and eviction reach directly, bypassing afterCommand) no more sub-commands are coming, so the per-sub-command ordering doesn't apply. Putting regular first there matches the in-process registration order of the existing API, so active-expire and eviction produce byte-identical streams across both APIs. The per-mode branches in Test active expire and Test eviction collapse into the common foreach body. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server.c | 16 ++-- tests/unit/moduleapi/postnotifications.tcl | 87 +++++++--------------- 2 files changed, 37 insertions(+), 66 deletions(-) diff --git a/src/server.c b/src/server.c index 9d1420d7e..c3234fbac 100644 --- a/src/server.c +++ b/src/server.c @@ -3849,14 +3849,16 @@ void postExecutionUnitOperations(void) { if (server.execution_nesting) return; - /* Drain keyed post-notification jobs before the regular ones. This is the - * exit-execution-unit firing site that mirrors afterCommand's drain - * order, and it is the point at which active-expire (expire.c) and - * eviction (evict.c) get the per-key callback delivered — both call us - * directly after their own enter/exitExecutionUnit. */ - firePostKeyedNotificationJobs(); - + /* At this site the regular queue drains first and the keyed queue runs + * after it. We're past every command's afterCommand drain by now (no + * more sub-commands coming), so the per-sub-command ordering that + * afterCommand needs doesn't apply here. Keeping regular first matches + * the in-process registration order of the existing API and gives + * identical propagated streams between the two APIs for the cron-driven + * paths (active-expire in expire.c, eviction in evict.c — both call us + * directly after their own enter/exitExecutionUnit). */ firePostExecutionUnitJobs(); + firePostKeyedNotificationJobs(); /* If we are at the top-most call() and not inside a an active module * context (e.g. within a module timer) we can propagate what we accumulated. */ diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index 66aaf4420..dde15a00a 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -81,39 +81,22 @@ foreach api {regular perkey} { fail "Failed waiting for x to expired" } - # {lpush before_expired x} is a post-notification job registered - # via the RedisModuleEvent_Key/before_expired server event, which - # always uses the regular post-notif queue. {incr expired} is the - # keyspace-handler side-effect — registered on the regular queue - # in regular mode and on the per-key queue in perkey mode. - # - # Both APIs drain at postExecutionUnitOperations (called from - # activeExpireCycleTryExpire). In regular mode both jobs share - # the regular queue and fire in registration order. In perkey - # mode the per-key queue is drained before the regular queue, - # so {incr expired} propagates first. - if {$api eq "perkey"} { - assert_replication_stream $repl { - {select *} - {set x 1} - {pexpireat x *} - {multi} - {del x} - {incr expired} - {lpush before_expired x} - {exec} - } - } else { - assert_replication_stream $repl { - {select *} - {set x 1} - {pexpireat x *} - {multi} - {del x} - {lpush before_expired x} - {incr expired} - {exec} - } + # {lpush before_expired x} comes from the RedisModuleEvent_Key + # server event (always uses the regular post-notif queue). + # {incr expired} comes from the keyspace handler (regular or + # per-key queue depending on $api). Both APIs propagate the + # same stream: postExecutionUnitOperations drains regular + # before per-key, so the ordering between the two side-effects + # matches their in-process registration order. + assert_replication_stream $repl { + {select *} + {set x 1} + {pexpireat x *} + {multi} + {del x} + {lpush before_expired x} + {incr expired} + {exec} } close_replication_stream $repl } @@ -221,34 +204,20 @@ foreach api {regular perkey} { assert_error {OOM *} {r set y 1} - # {lpush before_evicted x} is registered via the + # {lpush before_evicted x} comes from the # RedisModuleEvent_Key/before_evicted server event (always uses # the regular post-notif queue). {incr evicted} comes from the - # keyspace handler — on the regular queue in regular mode and - # on the per-key queue in perkey mode. Both APIs drain in - # postExecutionUnitOperations (called from performEvictions); - # in perkey mode the per-key queue is drained first, so - # {incr evicted} propagates before {lpush before_evicted x}. - if {$api eq "perkey"} { - assert_replication_stream $repl { - {select *} - {set x 1} - {multi} - {del x} - {incr evicted} - {lpush before_evicted x} - {exec} - } - } else { - assert_replication_stream $repl { - {select *} - {set x 1} - {multi} - {del x} - {lpush before_evicted x} - {incr evicted} - {exec} - } + # keyspace handler (regular or per-key queue depending on + # $api). Both APIs propagate the same stream: regular drains + # before per-key inside postExecutionUnitOperations. + assert_replication_stream $repl { + {select *} + {set x 1} + {multi} + {del x} + {lpush before_evicted x} + {incr evicted} + {exec} } close_replication_stream $repl } {} {needs:config-maxmemory} From f13e7534a9418746dcfbe664903f6c4c02f39489 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Fri, 22 May 2026 18:02:52 +0200 Subject: [PATCH 19/24] parametrize Test nested keyspace notification over both APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler doesn't touch the post-notification API at all — it does an immediate RM_Call inside the keyspace callback to exercise REDISMODULE_OPTIONS_ALLOW_NESTED_KEYSPACE_NOTIFICATIONS — so the stream is byte-identical in regular and perkey modes. Run it under both for cosmetic uniformity in the test table. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/moduleapi/postnotifications.tcl | 32 ++++++++++++---------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index dde15a00a..924701651 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -167,25 +167,27 @@ foreach api {regular perkey} { } } -tags "modules external:skip" { - start_server {} { - r module load $testmodule with_key_events +foreach api {regular perkey} { + tags "modules external:skip" { + start_server {} { + r module load $testmodule $api with_key_events - test {Test nested keyspace notification} { - r flushall - set repl [attach_to_replication_stream] + test "Test nested keyspace notification ($api API)" { + r flushall + set repl [attach_to_replication_stream] - assert_equal {OK} [r set write_sync_write_sync_x 1] + assert_equal {OK} [r set write_sync_write_sync_x 1] - assert_replication_stream $repl { - {multi} - {select *} - {set x 1} - {set write_sync_x 1} - {set write_sync_write_sync_x 1} - {exec} + assert_replication_stream $repl { + {multi} + {select *} + {set x 1} + {set write_sync_x 1} + {set write_sync_write_sync_x 1} + {exec} + } + close_replication_stream $repl } - close_replication_stream $repl } } } From 6cb6f51b1cc0b41d4e1032a09f0b2786b7b66db6 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Mon, 25 May 2026 11:58:27 +0200 Subject: [PATCH 20/24] allow to add PostNotificationJobsPerKey during AOF replay --- src/aof.c | 12 + src/module.c | 48 +++- src/server.c | 22 +- tests/modules/Makefile | 1 + .../postnotifications_perkey_metadata.c | 175 ++++++++++++ .../postnotifications_perkey_aof_repl.tcl | 266 ++++++++++++++++++ 6 files changed, 506 insertions(+), 18 deletions(-) create mode 100644 tests/modules/postnotifications_perkey_metadata.c create mode 100644 tests/unit/moduleapi/postnotifications_perkey_aof_repl.tcl diff --git a/src/aof.c b/src/aof.c index 9e55a78b7..df80861b9 100644 --- a/src/aof.c +++ b/src/aof.c @@ -1681,6 +1681,12 @@ int loadSingleAppendOnlyFile(char *filename) { queueMultiCommand(fakeClient, cmd->flags); } else { cmd->proc(fakeClient); + /* AOF replay bypasses call()/afterCommand(); drain the per-key + * post-notification queue here so module callbacks fire once per + * replayed single command, mirroring the normal-execution path. + * EXEC's sub-commands run through call() above and are drained + * by afterCommand() for each sub-command. */ + firePostKeyedNotificationJobs(); fakeClient->all_argv_len_sum = 0; /* Otherwise no one cleans this up and we reach cleanup with it non-zero */ } @@ -1760,6 +1766,12 @@ fmterr: /* Format error. */ /* fall through to cleanup. */ cleanup: + /* Drain any per-key post-notification jobs left over from a partially + * applied command before tearing down the fake client. In the success + * path the per-iteration drain after cmd->proc() has already emptied + * the queue; this catches stragglers from any aborted-mid-execution + * path so the next caller doesn't observe stale jobs. */ + firePostKeyedNotificationJobs(); if (fakeClient) freeClient(fakeClient); server.current_client = old_cur_client; server.executing_client = old_exec_client; diff --git a/src/module.c b/src/module.c index 7faaad12c..737647943 100644 --- a/src/module.c +++ b/src/module.c @@ -9464,7 +9464,23 @@ void firePostExecutionUnitJobs(void) { * Invoked at the tail of every call() (see afterCommand), so callbacks fire * between sub-commands inside MULTI/EXEC. Uses server.firing_keyed_post_notif_jobs * as a reentrance guard, since the per-call() hook bypasses the execution_nesting - * gating used by firePostExecutionUnitJobs. */ + * gating used by firePostExecutionUnitJobs. + * + * Also invoked from the AOF replay loop in loadSingleAppendOnlyFile after + * each single command (sub-commands inside MULTI/EXEC are drained by + * afterCommand() since EXEC's processor goes through call()). This is what + * makes the firing pattern during AOF replay match normal command + * execution: one drain per single command, one per MULTI/EXEC sub-command. + * + * Contract for the callback: it MUST only touch non-replicated, + * non-AOF-persisted state — module-attached key metadata is the canonical + * case. The same callback fires on the master, on every replica receiving + * master-propagated commands, and during AOF replay; consistency relies on + * each instance running the same callback over the same KSN stream, not + * on replication of the callback's side-effects. Writes back into the + * keyspace via RM_Call(..., "!...") will duplicate effects already in the + * AOF on replay and diverge the replica from its master in steady state. + * The runtime does not enforce this; it is a documented contract. */ void firePostKeyedNotificationJobs(void) { if (server.firing_keyed_post_notif_jobs) return; if (listLength(modulePostKeyedNotificationJobs) == 0) return; @@ -9534,18 +9550,36 @@ int RM_AddPostNotificationJob(RedisModuleCtx *ctx, RedisModulePostNotificationJo * - At the end of the outermost execution unit, alongside the regular * post-notification jobs. This covers the active-expire and eviction * paths (both call `postExecutionUnitOperations` themselves). + * - During AOF replay, at the tail of every replayed command — single + * commands and each sub-command of MULTI/EXEC — so per-key state that + * lives outside the AOF (e.g. module-attached key metadata) is rebuilt + * in the same pattern as during normal execution. * * Jobs fire in submission order. `key` must be a valid RedisModuleString; the * implementation takes its own reference and the caller retains ownership of * its own reference. `free_pd` may be NULL. * - * Return REDISMODULE_OK on success and REDISMODULE_ERR if called while - * loading data from disk (AOF or RDB), on a read-only replica, or outside a - * keyspace-notification handler. */ + * Cross-phase contract (enforced by documentation, not by the runtime): + * - The callback MUST only touch non-replicated, non-AOF-persisted state, + * such as module-attached key metadata via `RM_SetKeyMeta` / + * `RM_GetKeyMeta`. The same callback fires on the master, on every + * replica receiving master-propagated commands, and during AOF replay; + * each instance maintains its own per-key state independently and they + * converge because they all run the same callback over the same KSN + * stream. + * - Touching the keyspace from inside the callback (e.g. + * `RM_Call(..., "!...")`) is a contract violation. On AOF replay it + * amplifies the AOF; on a replica it diverges the replica from its + * master. The runtime does not suppress propagation inside the per-key + * drain and does not enforce this contract today — modules registering + * per-key jobs are responsible for upholding it. A future change may + * add runtime checks if reviewers want stronger enforcement. + * + * Return REDISMODULE_OK on success and REDISMODULE_ERR if called outside a + * keyspace-notification handler. The API is permitted on read-only replicas + * and during AOF replay, so per-key state stays continuously in sync with + * the keyspace events the instance observes. */ int RM_AddPostNotificationJobForKey(RedisModuleCtx *ctx, RedisModulePostNotificationJobPerKeyFunc callback, RedisModuleString *key, void *privdata, void (*free_privdata)(void*)) { - if (server.loading || (server.masterhost && server.repl_slave_ro)) { - return REDISMODULE_ERR; - } /* The API is only meaningful from inside a keyspace-notification handler: * that is the single-key context the per-key contract is scoped to. */ diff --git a/src/server.c b/src/server.c index c3234fbac..3c00943cd 100644 --- a/src/server.c +++ b/src/server.c @@ -186,7 +186,7 @@ void _serverLog(int level, const char *fmt, ...) { serverLogRaw(level,msg); } -/* Low level logging from signal handler. Should be used with pre-formatted strings. +/* Low level logging from signal handler. Should be used with pre-formatted strings. See serverLogFromHandler. */ void serverLogRawFromHandler(int level, const char *msg) { int fd; @@ -274,7 +274,7 @@ mstime_t commandTimeSnapshot(void) { /* After an RDB dump or AOF rewrite we exit from children using _exit() instead of * exit(), because the latter may interact with the same file objects used by * the parent process. However if we are testing the coverage normal exit() is - * used in order to obtain the right coverage information. + * used in order to obtain the right coverage information. * There is a caveat for when we exit due to a signal. * In this case we want the function to be async signal safe, so we can't use exit() */ @@ -793,7 +793,7 @@ dictType clientDictType = { NULL, /* val dup */ dictClientKeyCompare, /* key compare */ .no_value = 1, /* no values in this dict */ - .keys_are_odd = 0 /* a client pointer is not an odd pointer */ + .keys_are_odd = 0 /* a client pointer is not an odd pointer */ }; kvstoreType kvstoreBaseType = { @@ -1735,7 +1735,7 @@ int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) { * a higher frequency. */ run_with_period(1000) { if ((server.aof_state == AOF_ON || server.aof_state == AOF_WAIT_REWRITE) && - server.aof_last_write_status == C_ERR) + server.aof_last_write_status == C_ERR) { flushAppendOnlyFile(0); } @@ -1745,8 +1745,8 @@ int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) { updatePausedActions(); /* Replication cron function -- used to reconnect to master, - * detect transfer failures, start background RDB transfers and so forth. - * + * detect transfer failures, start background RDB transfers and so forth. + * * If Redis is trying to failover then run the replication cron faster so * progress on the handshake happens more quickly. */ if (server.failover_state != NO_FAILOVER) { @@ -1986,7 +1986,7 @@ void beforeSleep(struct aeEventLoop *eventLoop) { * processUnblockedClients(), so if there are multiple pipelined WAITs * and the just unblocked WAIT gets blocked again, we don't have to wait * a server cron cycle in absence of other event loop events. See #6623. - * + * * We also don't send the ACKs while clients are paused, since it can * increment the replication backlog, they'll be sent after the pause * if we are still the master. */ @@ -1996,7 +1996,7 @@ void beforeSleep(struct aeEventLoop *eventLoop) { } /* We may have received updates from clients about their current offset. NOTE: - * this can't be done where the ACK is received since failover will disconnect + * this can't be done where the ACK is received since failover will disconnect * our clients. */ updateFailoverStatus(); @@ -4760,12 +4760,12 @@ int processCommand(client *c) { /* If the server is paused, block the client until * the pause has ended. Replicas are never paused. */ - if (!(c->flags & CLIENT_SLAVE) && + if (!(c->flags & CLIENT_SLAVE) && ((isPausedActions(PAUSE_ACTION_CLIENT_ALL)) || ((isPausedActions(PAUSE_ACTION_CLIENT_WRITE)) && is_may_replicate_command))) { blockPostponeClient(c); - return C_OK; + return C_OK; } /* Exec the command */ @@ -6930,7 +6930,7 @@ sds genRedisInfoString(dict *section_dict, int all_sections, int everything) { /* Hotkeys */ if (all_sections || (dictFind(section_dict,"hotkeys") != NULL)) { - if (sections++) info = sdscat(info,"\r\n"); + if (sections++) info = sdscat(info,"\r\n"); info = sdscatprintf(info, "# Hotkeys\r\n"); if (server.hotkeys) { diff --git a/tests/modules/Makefile b/tests/modules/Makefile index 0659497cf..a20d9530a 100644 --- a/tests/modules/Makefile +++ b/tests/modules/Makefile @@ -86,6 +86,7 @@ TEST_MODULES = \ configaccess.so \ test_keymeta.so \ keymeta_notify.so \ + postnotifications_perkey_metadata.so \ atomicslotmigration.so .PHONY: all diff --git a/tests/modules/postnotifications_perkey_metadata.c b/tests/modules/postnotifications_perkey_metadata.c new file mode 100644 index 000000000..41aa4b6fa --- /dev/null +++ b/tests/modules/postnotifications_perkey_metadata.c @@ -0,0 +1,175 @@ +/* Test module for RM_AddPostNotificationJobForKey firing across phases. + * + * Used by tests/unit/moduleapi/postnotifications_perkey_aof_repl.tcl. The + * module exercises the contract that a per-key post-notification callback + * MUST only touch non-replicated, non-AOF-persisted state — here, module + * key metadata. This lets the same callback fire on a master, on a replica + * receiving master-propagated commands, and during AOF replay, with each + * instance maintaining its per-key state independently. + * + * Subscribes to KSN events for STRING / HASH / GENERIC / EXPIRED / EVICTED. + * For each notification the KSN handler enqueues a per-key job; the job + * later attaches metadata via RM_SetKeyMeta. A module-internal counter + * (NOT a Redis key — to avoid AOF / replication pollution) records how + * many times the per-key job actually ran. + * + * Commands: + * pkmeta.getmeta - Return the metadata string, or nil. + * pkmeta.firecount - Return the module-internal fire counter. + * pkmeta.reset - Zero the fire counter. + * pkmeta.try_outside - Call RM_AddPostNotificationJobForKey from + * outside a KSN handler; reply OK/ERR for the + * negative-coverage test. + * + * Copyright (c) 2006-Present, Redis Ltd. + * All rights reserved. + * + * Licensed under your choice of (a) the Redis Source Available License 2.0 + * (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the + * GNU Affero General Public License v3 (AGPLv3). + */ + +#include "redismodule.h" +#include +#include + +static RedisModuleKeyMetaClassId meta_class_id = -1; + +/* Module-internal counter — kept out of the keyspace on purpose so it is + * neither replicated nor AOF-persisted. Tests assert on it to confirm the + * per-key callback actually ran. */ +static long long fire_count = 0; + +static void MetaFreeCallback(const char *keyname, uint64_t meta) { + REDISMODULE_NOT_USED(keyname); + if (meta != 0) free((char *)meta); +} + +/* Per-key post-notification job: attaches a "notified" string as metadata. + * Runs at the tail of the originating command (or sub-command for + * MULTI/EXEC), outside the KSN handler stack. */ +static void PerKeyMetadataJob(RedisModuleCtx *ctx, RedisModuleString *key, void *pd) { + REDISMODULE_NOT_USED(pd); + if (meta_class_id < 0) return; + + RedisModuleKey *k = RedisModule_OpenKey(ctx, key, REDISMODULE_WRITE); + if (!k) return; + if (RedisModule_KeyType(k) == REDISMODULE_KEYTYPE_EMPTY) { + RedisModule_CloseKey(k); + return; + } + + uint64_t existing = 0; + if (RedisModule_GetKeyMeta(meta_class_id, k, &existing) == REDISMODULE_OK && + existing != 0) { + free((char *)existing); + } + + char *new_str = strdup("notified"); + if (RedisModule_SetKeyMeta(meta_class_id, k, (uint64_t)new_str) == REDISMODULE_OK) { + fire_count++; + } else { + free(new_str); + } + RedisModule_CloseKey(k); +} + +/* KSN handler: defers SetKeyMeta into a per-key job rather than calling it + * inline. This is the path under test — the per-key API is what makes the + * write happen at a safe firing point, including during AOF replay and on + * a replica receiving propagated commands. */ +static int NotifyCallback(RedisModuleCtx *ctx, int type, const char *event, + RedisModuleString *key) { + REDISMODULE_NOT_USED(type); + REDISMODULE_NOT_USED(event); + RedisModule_AddPostNotificationJobForKey(ctx, PerKeyMetadataJob, key, NULL, NULL); + return REDISMODULE_OK; +} + +static int GetMetaCommand(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { + if (argc != 2) return RedisModule_WrongArity(ctx); + RedisModuleKey *k = RedisModule_OpenKey(ctx, argv[1], REDISMODULE_READ); + if (!k || RedisModule_KeyType(k) == REDISMODULE_KEYTYPE_EMPTY) { + if (k) RedisModule_CloseKey(k); + return RedisModule_ReplyWithNull(ctx); + } + uint64_t meta = 0; + if (RedisModule_GetKeyMeta(meta_class_id, k, &meta) == REDISMODULE_OK && meta != 0) { + RedisModule_ReplyWithCString(ctx, (const char *)meta); + } else { + RedisModule_ReplyWithNull(ctx); + } + RedisModule_CloseKey(k); + return REDISMODULE_OK; +} + +static int FireCountCommand(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { + REDISMODULE_NOT_USED(argv); + REDISMODULE_NOT_USED(argc); + return RedisModule_ReplyWithLongLong(ctx, fire_count); +} + +static int ResetCommand(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { + REDISMODULE_NOT_USED(argv); + REDISMODULE_NOT_USED(argc); + fire_count = 0; + return RedisModule_ReplyWithSimpleString(ctx, "OK"); +} + +/* Calls RM_AddPostNotificationJobForKey from outside a KSN handler — must + * return REDISMODULE_ERR. Used by the negative coverage test. */ +static int TryOutsideCommand(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { + if (argc != 2) return RedisModule_WrongArity(ctx); + int rc = RedisModule_AddPostNotificationJobForKey(ctx, PerKeyMetadataJob, + argv[1], NULL, NULL); + if (rc == REDISMODULE_OK) { + return RedisModule_ReplyWithSimpleString(ctx, "OK"); + } + return RedisModule_ReplyWithError(ctx, "ERR registration refused"); +} + +int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { + REDISMODULE_NOT_USED(argv); + REDISMODULE_NOT_USED(argc); + + if (RedisModule_Init(ctx, "pkmeta", 1, REDISMODULE_APIVER_1) == REDISMODULE_ERR) + return REDISMODULE_ERR; + + RedisModuleKeyMetaClassConfig config = {0}; + config.version = REDISMODULE_KEY_META_VERSION; + config.flags = (1 << REDISMODULE_META_ALLOW_IGNORE); + config.reset_value = (uint64_t)NULL; + config.free = MetaFreeCallback; + meta_class_id = RedisModule_CreateKeyMetaClass(ctx, "pkmc", 1, &config); + if (meta_class_id < 0) return REDISMODULE_ERR; + + int notifyFlags = REDISMODULE_NOTIFY_GENERIC | REDISMODULE_NOTIFY_HASH | + REDISMODULE_NOTIFY_STRING | REDISMODULE_NOTIFY_EXPIRED | + REDISMODULE_NOTIFY_EVICTED; + if (RedisModule_SubscribeToKeyspaceEvents(ctx, notifyFlags, NotifyCallback) != REDISMODULE_OK) + return REDISMODULE_ERR; + + if (RedisModule_CreateCommand(ctx, "pkmeta.getmeta", GetMetaCommand, + "readonly", 1, 1, 1) == REDISMODULE_ERR) + return REDISMODULE_ERR; + if (RedisModule_CreateCommand(ctx, "pkmeta.firecount", FireCountCommand, + "readonly", 0, 0, 0) == REDISMODULE_ERR) + return REDISMODULE_ERR; + if (RedisModule_CreateCommand(ctx, "pkmeta.reset", ResetCommand, + "readonly", 0, 0, 0) == REDISMODULE_ERR) + return REDISMODULE_ERR; + if (RedisModule_CreateCommand(ctx, "pkmeta.try_outside", TryOutsideCommand, + "readonly", 1, 1, 1) == REDISMODULE_ERR) + return REDISMODULE_ERR; + + return REDISMODULE_OK; +} + +int RedisModule_OnUnload(RedisModuleCtx *ctx) { + REDISMODULE_NOT_USED(ctx); + if (meta_class_id >= 0) { + RedisModule_ReleaseKeyMetaClass(meta_class_id); + meta_class_id = -1; + } + return REDISMODULE_OK; +} diff --git a/tests/unit/moduleapi/postnotifications_perkey_aof_repl.tcl b/tests/unit/moduleapi/postnotifications_perkey_aof_repl.tcl new file mode 100644 index 000000000..db4996beb --- /dev/null +++ b/tests/unit/moduleapi/postnotifications_perkey_aof_repl.tcl @@ -0,0 +1,266 @@ +set testmodule [file normalize tests/modules/postnotifications_perkey_metadata.so] + +# AOF replay on a standalone master. +# +# Asserts that per-key post-notification jobs fire during AOF replay with the +# same pattern as during normal execution: once per single command, once per +# MULTI/EXEC sub-command. The per-key callback attaches module key metadata, +# which is NOT in the AOF — so its presence after reload is direct evidence +# that the callback re-ran during replay. The module-internal fire counter +# (read via `pkmeta.firecount`) is the load-bearing assertion. +tags "modules aof external:skip" { + foreach aofload_type {debug_cmd startup} { + test "perkey-aof: single command rebuilds metadata via AOF reload (load=$aofload_type)" { + start_server [list overrides [list loadmodule "$testmodule"]] { + r config set appendonly yes + r config set auto-aof-rewrite-percentage 0 + waitForBgrewriteaof r + + r hset h1 f v + assert_equal "notified" [r pkmeta.getmeta h1] + assert_equal 1 [r pkmeta.firecount] + + # Reset the counter so the post-reload count reflects only + # what the AOF replay path produced. + r pkmeta.reset + + if {$aofload_type == "debug_cmd"} { + r debug loadaof + } else { + r config rewrite + restart_server 0 true false + wait_done_loading r + } + + assert_equal "notified" [r pkmeta.getmeta h1] + assert_equal 1 [r pkmeta.firecount] + } + } + + test "perkey-aof: MULTI/EXEC fires once per sub-command during AOF reload (load=$aofload_type)" { + start_server [list overrides [list loadmodule "$testmodule"]] { + r config set appendonly yes + r config set auto-aof-rewrite-percentage 0 + waitForBgrewriteaof r + + r multi + r hset h1 f v + r hset h2 f v + r hset h3 f v + r exec + + assert_equal 3 [r pkmeta.firecount] + r pkmeta.reset + + if {$aofload_type == "debug_cmd"} { + r debug loadaof + } else { + r config rewrite + restart_server 0 true false + wait_done_loading r + } + + # Each sub-command's per-key drain must have fired during + # replay — three HSETs → three callback invocations. + assert_equal 3 [r pkmeta.firecount] + assert_equal "notified" [r pkmeta.getmeta h1] + assert_equal "notified" [r pkmeta.getmeta h2] + assert_equal "notified" [r pkmeta.getmeta h3] + } + } + + test "perkey-aof: HSET + HEXPIRE in MULTI/EXEC fires twice during AOF reload (load=$aofload_type)" { + start_server [list overrides [list loadmodule "$testmodule"]] { + r config set appendonly yes + r config set auto-aof-rewrite-percentage 0 + waitForBgrewriteaof r + + r multi + r hset h_hexp f v + r hexpire h_hexp 100 FIELDS 1 f + r exec + + assert_equal 2 [r pkmeta.firecount] + r pkmeta.reset + + if {$aofload_type == "debug_cmd"} { + r debug loadaof + } else { + r config rewrite + restart_server 0 true false + wait_done_loading r + } + + # HSET + HEXPIRE on the same key — two KSN events, two + # per-key job firings. This was the original motivating + # scenario (RED-197766). + assert_equal 2 [r pkmeta.firecount] + assert_equal "notified" [r pkmeta.getmeta h_hexp] + } + } + } +} + +# RDB load is intentionally outside the firing pattern. +# +# RDB load decodes keys directly without running commands, so no KSN fires, +# so per-key callbacks do not run. This test pins that design boundary: if +# anyone later "fixes" RDB load to fire KSN, this assertion will break and +# force the change to be considered explicitly. +tags "modules external:skip" { + test "perkey-rdb: RDB-only restart does NOT rebuild metadata (no KSN on RDB load)" { + start_server [list overrides [list loadmodule "$testmodule" appendonly no]] { + r hset h_rdb f v + assert_equal "notified" [r pkmeta.getmeta h_rdb] + assert_equal 1 [r pkmeta.firecount] + + r pkmeta.reset + r debug reload + + # RDB roundtrip: the key is back, but its metadata is not (the + # metadata class doesn't persist via rdb_save/rdb_load in this + # module), and the per-key job did NOT fire during load. + assert_equal "hash" [r type h_rdb] + assert_equal {} [r pkmeta.getmeta h_rdb] + assert_equal 0 [r pkmeta.firecount] + } + } {} {needs:debug} +} + +# AOF replay on a replica at startup. +# +# Exercises the carve-out in RM_AddPostNotificationJobForKey that permits +# registration during loading even when masterhost is set. Without that +# carve-out the per-key job would be refused on a replica's own AOF replay +# and metadata would not be rebuilt at startup. +tags "modules aof external:skip" { + test "perkey-aof-replica: AOF replay on a replica at startup rebuilds metadata" { + # Master is loaded with the module too so its propagation path is + # comparable to a real deployment. The point under test is the AOF + # replay step on the replica at restart, not the initial sync. + start_server [list overrides [list loadmodule "$testmodule"]] { + set master [srv 0 client] + set master_host [srv 0 host] + set master_port [srv 0 port] + + start_server [list overrides [list \ + loadmodule $testmodule \ + appendonly yes \ + auto-aof-rewrite-percentage 0 \ + replicaof "$master_host $master_port"]] { + set replica [srv 0 client] + wait_for_sync $replica + + # Drive a write on the master; replica receives it via + # propagation and writes it to its own AOF. + $master hset h_repl f v + wait_for_ofs_sync $master $replica + + # Sanity: key did propagate + assert_equal 1 [$replica hexists h_repl f] + assert_equal "notified" [$replica pkmeta.getmeta h_repl] + $replica pkmeta.reset + + # Use debug loadaof to exercise the AOF replay path + # specifically on a configured replica (masterhost set, + # repl_slave_ro true, server.loading=1). A full restart + # would re-sync from master via RDB and wipe metadata — + # that is a separate code path. We deliberately do not + # rewrite the AOF here: rewriting converts the HSET into + # the RDB-encoded base AOF, and RDB load (preamble or + # otherwise) intentionally does not fire KSN. The + # incremental AOF — which is what propagated commands + # land in — is what the per-key drain runs against. + $replica debug loadaof + + # The AOF reload fires the per-key job on the replica; the + # callback runs with masterhost set, repl_slave_ro on, and + # server.loading == 1, which is exactly the carve-out. + assert_equal "notified" [$replica pkmeta.getmeta h_repl] + assert {[$replica pkmeta.firecount] >= 1} + } + } + } +} + +# Master → replica steady-state propagation. +# +# With the replica check dropped, per-key jobs fire on the replica too: +# both sides run the same KSN over the same command stream and maintain +# their per-key state independently. No metadata traffic on the wire. +tags "modules external:skip" { + test "perkey-repl: replica builds metadata from master-propagated single command" { + start_server [list overrides [list loadmodule "$testmodule"]] { + set replica [srv 0 client] + set replica_host [srv 0 host] + set replica_port [srv 0 port] + start_server [list overrides [list loadmodule "$testmodule"]] { + set master [srv 0 client] + set master_host [srv 0 host] + set master_port [srv 0 port] + + $replica replicaof $master_host $master_port + wait_for_sync $replica + + $master pkmeta.reset + $replica pkmeta.reset + + $master hset h_prop f v + wait_for_ofs_sync $master $replica + + # Both sides ran the per-key job locally — no metadata + # crossed the replication stream. + assert_equal "notified" [$master pkmeta.getmeta h_prop] + assert_equal "notified" [$replica pkmeta.getmeta h_prop] + assert_equal 1 [$master pkmeta.firecount] + assert_equal 1 [$replica pkmeta.firecount] + } + } + } + + test "perkey-repl: replica fires per sub-command for propagated MULTI/EXEC" { + start_server [list overrides [list loadmodule "$testmodule"]] { + set replica [srv 0 client] + set replica_host [srv 0 host] + set replica_port [srv 0 port] + start_server [list overrides [list loadmodule "$testmodule"]] { + set master [srv 0 client] + set master_host [srv 0 host] + set master_port [srv 0 port] + + $replica replicaof $master_host $master_port + wait_for_sync $replica + + $master pkmeta.reset + $replica pkmeta.reset + + $master multi + $master hset hp1 f v + $master hset hp2 f v + $master hset hp3 f v + $master exec + wait_for_ofs_sync $master $replica + + assert_equal 3 [$master pkmeta.firecount] + assert_equal 3 [$replica pkmeta.firecount] + foreach key {hp1 hp2 hp3} { + assert_equal "notified" [$master pkmeta.getmeta $key] + assert_equal "notified" [$replica pkmeta.getmeta $key] + } + } + } + } +} + +# Negative coverage: API misuse outside a KSN handler. +# +# The only remaining runtime guard. Calling RM_AddPostNotificationJobForKey +# from a regular module command (not a KSN handler) must return +# REDISMODULE_ERR with a LL_WARNING log entry. +tags "modules external:skip" { + test "perkey-misuse: registration refused outside a KSN handler" { + start_server [list overrides [list loadmodule "$testmodule"]] { + assert_error {ERR registration refused*} {r pkmeta.try_outside any_key} + } + } +} From f899ad63e5c7ba9189623dab6b6d58fde17d3cfa Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Mon, 25 May 2026 12:28:47 +0200 Subject: [PATCH 21/24] add limitation comment --- src/module.c | 11 +++++++++++ src/server.c | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/module.c b/src/module.c index 737647943..a8a135dee 100644 --- a/src/module.c +++ b/src/module.c @@ -9574,6 +9574,17 @@ int RM_AddPostNotificationJob(RedisModuleCtx *ctx, RedisModulePostNotificationJo * drain and does not enforce this contract today — modules registering * per-key jobs are responsible for upholding it. A future change may * add runtime checks if reviewers want stronger enforcement. + * - Causing a regular post-notification job to be queued from inside a + * per-key callback is unsupported. The outermost drain in + * `postExecutionUnitOperations` runs the regular queue first and the + * per-key queue second; a regular job appended during the per-key drain + * (for example via an `RM_Call` whose KSN handler in another module + * calls `RM_AddPostNotificationJob`) is not drained before + * `propagatePendingCommands`, so its writes land in a separate + * replication transaction from the originating command. This falls out + * naturally if the keyspace-mutation contract above is upheld, since + * keyed callbacks would not be issuing the writes that surface the + * cross-queueing KSN in the first place. * * Return REDISMODULE_OK on success and REDISMODULE_ERR if called outside a * keyspace-notification handler. The API is permitted on read-only replicas diff --git a/src/server.c b/src/server.c index 3c00943cd..17f7c0f3d 100644 --- a/src/server.c +++ b/src/server.c @@ -3856,7 +3856,16 @@ void postExecutionUnitOperations(void) { * the in-process registration order of the existing API and gives * identical propagated streams between the two APIs for the cron-driven * paths (active-expire in expire.c, eviction in evict.c — both call us - * directly after their own enter/exitExecutionUnit). */ + * directly after their own enter/exitExecutionUnit). + * + * Cross-queue registration during these drains is unsupported: a keyed + * callback that causes a regular job to be queued (e.g. via a KSN + * surfaced from its RM_Call landing in another module's handler) leaves + * that regular job in the queue past propagatePendingCommands, so its + * writes land in a separate replication transaction. The contract + * around what keyed callbacks may do (documented on + * RM_AddPostNotificationJobForKey in module.c) is what keeps this from + * triggering in practice; modules are responsible for upholding it. */ firePostExecutionUnitJobs(); firePostKeyedNotificationJobs(); From 1116b5cdbc296119726c117c79798a854f005c2d Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Mon, 25 May 2026 13:44:27 +0200 Subject: [PATCH 22/24] fix test --- tests/unit/moduleapi/postnotifications_perkey_aof_repl.tcl | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/moduleapi/postnotifications_perkey_aof_repl.tcl b/tests/unit/moduleapi/postnotifications_perkey_aof_repl.tcl index db4996beb..858066ae1 100644 --- a/tests/unit/moduleapi/postnotifications_perkey_aof_repl.tcl +++ b/tests/unit/moduleapi/postnotifications_perkey_aof_repl.tcl @@ -150,6 +150,12 @@ tags "modules aof external:skip" { replicaof "$master_host $master_port"]] { set replica [srv 0 client] wait_for_sync $replica + # The replica boots with appendonly=yes and replicaof, so + # post-sync it kicks off a background AOF rewrite. Until that + # child finishes, propagated commands land in a temp incr + # file that `debug loadaof` won't see — wait it out before + # driving the write under test. + waitForBgrewriteaof $replica # Drive a write on the master; replica receives it via # propagation and writes it to its own AOF. From bac0a1cc160cbc4e8b9b3d932ec899947c106ac6 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Date: Tue, 26 May 2026 10:28:54 +0200 Subject: [PATCH 23/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/modules/postnotifications.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/modules/postnotifications.c b/tests/modules/postnotifications.c index bf7a7205f..4f8a5727b 100644 --- a/tests/modules/postnotifications.c +++ b/tests/modules/postnotifications.c @@ -12,10 +12,11 @@ /* This module supports both the regular post-notification API * (RedisModule_AddPostNotificationJob) and the per-key API - * (RedisModule_AddPostNotificationJobForKey). A required load arg — + * (RedisModule_AddPostNotificationJobForKey). A load arg — * "regular" or "perkey" — selects which API the keyspace handlers register - * against. The keyspace handlers and post-job side effects are otherwise - * unchanged: only the registration call differs between modes. */ + * against (defaults to "regular" if omitted). The keyspace handlers and post-job + * side effects are otherwise unchanged: only the registration call differs + * between modes. */ #define _BSD_SOURCE #define _DEFAULT_SOURCE /* For usleep */ From e2bbd5e4edbdcfc7938e6c517d6e4ed79dd422c8 Mon Sep 17 00:00:00 2001 From: Joan Fontanals Martinez Date: Tue, 26 May 2026 10:30:23 +0200 Subject: [PATCH 24/24] review from AI changes --- src/module.c | 284 +++++++++++---------- tests/unit/moduleapi/postnotifications.tcl | 2 +- 2 files changed, 147 insertions(+), 139 deletions(-) diff --git a/src/module.c b/src/module.c index a8a135dee..3389f26cd 100644 --- a/src/module.c +++ b/src/module.c @@ -148,7 +148,7 @@ struct RedisModuleCtx { gets called for clients blocked on keys. */ - /* Used if there is the REDISMODULE_CTX_KEYS_POS_REQUEST or + /* Used if there is the REDISMODULE_CTX_KEYS_POS_REQUEST or * REDISMODULE_CTX_CHANNEL_POS_REQUEST flag set. */ getKeysResult *keys_result; @@ -476,8 +476,8 @@ struct ModuleConfig { sds name; /* Fullname of the config (as it appears in the config file) */ sds alias; /* Optional alias for the configuration. NULL if none exists */ - int unprefixedFlag; /* Indicates if the REDISMODULE_CONFIG_UNPREFIXED flag was set. - * If the configuration name was prefixed,during get_fn/set_fn + int unprefixedFlag; /* Indicates if the REDISMODULE_CONFIG_UNPREFIXED flag was set. + * If the configuration name was prefixed,during get_fn/set_fn * callbacks, it should be reported without the prefix */ void *privdata; /* Optional data passed into the module config callbacks */ @@ -1116,14 +1116,14 @@ int RM_IsChannelsPositionRequest(RedisModuleCtx *ctx) { * registration, the command implementation checks for this special call * using the RedisModule_IsChannelsPositionRequest() API and uses this * function in order to report the channels. - * + * * The supported flags are: * * REDISMODULE_CMD_CHANNEL_SUBSCRIBE: This command will subscribe to the channel. * * REDISMODULE_CMD_CHANNEL_UNSUBSCRIBE: This command will unsubscribe from this channel. * * REDISMODULE_CMD_CHANNEL_PUBLISH: This command will publish to this channel. - * * REDISMODULE_CMD_CHANNEL_PATTERN: Instead of acting on a specific channel, will act on any + * * REDISMODULE_CMD_CHANNEL_PATTERN: Instead of acting on a specific channel, will act on any * channel specified by the pattern. This is the same access - * used by the PSUBSCRIBE and PUNSUBSCRIBE commands available + * used by the PSUBSCRIBE and PUNSUBSCRIBE commands available * in Redis. Not intended to be used with PUBLISH permissions. * * The following is an example of how it could be used: @@ -1531,13 +1531,13 @@ int populateArgsStructure(struct redisCommandArg *args) { /* RedisModule_AddACLCategory can be used to add new ACL command categories. Category names * can only contain alphanumeric characters, underscores, or dashes. Categories can only be added - * during the RedisModule_OnLoad function. Once a category has been added, it can not be removed. + * during the RedisModule_OnLoad function. Once a category has been added, it can not be removed. * Any module can register a command to any added categories using RedisModule_SetCommandACLCategories. - * + * * Returns: - * - REDISMODULE_OK on successfully adding the new ACL category. + * - REDISMODULE_OK on successfully adding the new ACL category. * - REDISMODULE_ERR on failure. - * + * * On error the errno is set to: * - EINVAL if the name contains invalid characters. * - EBUSY if the category name already exists. @@ -1583,9 +1583,9 @@ int matchAclCategoryFlag(char *flag, int64_t *acl_categories_flags) { } /* Helper for RM_SetCommandACLCategories(). Turns a string representing acl category - * flags into the acl category flags used by Redis ACL which allows users to access + * flags into the acl category flags used by Redis ACL which allows users to access * the module commands by acl categories. - * + * * It returns the set of acl flags, or -1 if unknown flags are found. */ int64_t categoryFlagsFromString(char *aclflags) { int count, j; @@ -1606,12 +1606,12 @@ int64_t categoryFlagsFromString(char *aclflags) { /* RedisModule_SetCommandACLCategories can be used to set ACL categories to module * commands and subcommands. The set of ACL categories should be passed as * a space separated C string 'aclflags'. - * - * Example, the acl flags 'write slow' marks the command as part of the write and + * + * Example, the acl flags 'write slow' marks the command as part of the write and * slow ACL categories. - * + * * On success REDISMODULE_OK is returned. On error REDISMODULE_ERR is returned. - * + * * This function can only be called during the RedisModule_OnLoad function. If called * outside of this function, an error is returned. */ @@ -1843,7 +1843,7 @@ int RM_SetCommandACLCategories(RedisModuleCommand *command, const char *aclflags * * Other flags: * - * * `REDISMODULE_CMD_KEY_NOT_KEY`: The key is not actually a key, but + * * `REDISMODULE_CMD_KEY_NOT_KEY`: The key is not actually a key, but * should be routed in cluster mode as if it was a key. * * * `REDISMODULE_CMD_KEY_INCOMPLETE`: The keyspec might not point out all @@ -2424,7 +2424,7 @@ ustime_t RM_CachedMicroseconds(void) { * RM_BlockedClientMeasureTimeStart() and RM_BlockedClientMeasureTimeEnd() * to accumulate independent time intervals to the background duration. * This method always return REDISMODULE_OK. - * + * * This function is not thread safe, If used in module thread and blocked callback (possibly main thread) * simultaneously, it's recommended to protect them with lock owned by caller instead of GIL. */ int RM_BlockedClientMeasureTimeStart(RedisModuleBlockedClient *bc) { @@ -2437,7 +2437,7 @@ int RM_BlockedClientMeasureTimeStart(RedisModuleBlockedClient *bc) { * On success REDISMODULE_OK is returned. * This method only returns REDISMODULE_ERR if no start time was * previously defined ( meaning RM_BlockedClientMeasureTimeStart was not called ). - * + * * This function is not thread safe, If used in module thread and blocked callback (possibly main thread) * simultaneously, it's recommended to protect them with lock owned by caller instead of GIL. */ int RM_BlockedClientMeasureTimeEnd(RedisModuleBlockedClient *bc) { @@ -2551,7 +2551,7 @@ void RM_Yield(RedisModuleCtx *ctx, int flags, const char *busy_reply) { * * REDISMODULE_OPTION_NO_IMPLICIT_SIGNAL_MODIFIED: * See RM_SignalModifiedKey(). - * + * * REDISMODULE_OPTIONS_HANDLE_REPL_ASYNC_LOAD: * Setting this flag indicates module awareness of diskless async replication (repl-diskless-load=swapdb) * and that redis could be serving reads during replication instead of blocking with LOADING status. @@ -3288,9 +3288,9 @@ int RM_ReplyWithArray(RedisModuleCtx *ctx, long len) { * * If the connected client is using RESP2, the reply will be converted to a flat * array. - * + * * Use RM_ReplySetMapLength() to set deferred length. - * + * * The function always returns REDISMODULE_OK. */ int RM_ReplyWithMap(RedisModuleCtx *ctx, long len) { return moduleReplyWithCollection(ctx, len, COLLECTION_REPLY_MAP); @@ -3307,7 +3307,7 @@ int RM_ReplyWithMap(RedisModuleCtx *ctx, long len) { * array type. * * Use RM_ReplySetSetLength() to set deferred length. - * + * * The function always returns REDISMODULE_OK. */ int RM_ReplyWithSet(RedisModuleCtx *ctx, long len) { return moduleReplyWithCollection(ctx, len, COLLECTION_REPLY_SET); @@ -3322,12 +3322,12 @@ int RM_ReplyWithSet(RedisModuleCtx *ctx, long len) { * See Reply APIs section for more details. * * Use RM_ReplySetAttributeLength() to set deferred length. - * + * * Not supported by RESP2 and will return REDISMODULE_ERR, otherwise * the function always returns REDISMODULE_OK. */ int RM_ReplyWithAttribute(RedisModuleCtx *ctx, long len) { if (ctx->client->resp == 2) return REDISMODULE_ERR; - + return moduleReplyWithCollection(ctx, len, COLLECTION_REPLY_ATTRIBUTE); } @@ -3574,7 +3574,7 @@ int RM_ReplyWithCallReply(RedisModuleCtx *ctx, RedisModuleCallReply *reply) { * a string into a C buffer, and then calling the function * RedisModule_ReplyWithStringBuffer() with the buffer and length. * - * In RESP3 the string is tagged as a double, while in RESP2 it's just a plain string + * In RESP3 the string is tagged as a double, while in RESP2 it's just a plain string * that the user will have to parse. * * The function always returns REDISMODULE_OK. */ @@ -3588,7 +3588,7 @@ int RM_ReplyWithDouble(RedisModuleCtx *ctx, double d) { /* Reply with a RESP3 BigNumber type. * Visit https://github.com/antirez/RESP3/blob/master/spec.md for more info about RESP3. * - * In RESP3, this is a string of length `len` that is tagged as a BigNumber, + * In RESP3, this is a string of length `len` that is tagged as a BigNumber, * however, it's up to the caller to ensure that it's a valid BigNumber. * In RESP2, this is just a plain bulk string response. * @@ -3744,7 +3744,7 @@ RedisModuleString *RM_GetClientUserNameById(RedisModuleCtx *ctx, uint64_t id) { errno = ENOENT; return NULL; } - + if (client->user == NULL) { errno = ENOTSUP; return NULL; @@ -4341,7 +4341,7 @@ int RM_SetExpire(RedisModuleKey *key, mstime_t expire) { return REDISMODULE_ERR; if (expire != REDISMODULE_NO_EXPIRE) { expire += commandTimeSnapshot(); - /* setExpire() might realloc kvobj */ + /* setExpire() might realloc kvobj */ key->kv = setExpire(key->ctx->client,key->db,key->key,expire); } else { removeExpire(key->db,key->key); @@ -4362,7 +4362,7 @@ mstime_t RM_GetAbsExpire(RedisModuleKey *key) { /* Set a new expire for the key. If the special expire * REDISMODULE_NO_EXPIRE is set, the expire is cancelled if there was * one (the same as the PERSIST command). - * + * * Note that the expire must be provided as a positive integer representing * the absolute Unix timestamp the key should have. * @@ -4426,8 +4426,8 @@ int RM_SetAbsExpire(RedisModuleKey *key, mstime_t expire) { * .free_effort = myMeta_FreeEffortCallback * } * - * Redis does NOT take ownership of the config structure itself. The `confPtr` - * parameter only needs to remain valid during the RM_CreateKeyMetaClass() call + * Redis does NOT take ownership of the config structure itself. The `confPtr` + * parameter only needs to remain valid during the RM_CreateKeyMetaClass() call * and can be freed immediately after. * * * **version**: Module must set it to REDISMODULE_KEY_META_VERSION. This field is @@ -4553,7 +4553,7 @@ RedisModuleKeyMetaClassId RM_CreateKeyMetaClass(RedisModuleCtx *ctx, void *confPtr) { RedisModuleKeyMetaClassId id; - + /* Allow registration during OnLoad, server startup, or when debug flag is set */ int ctx_flags = RM_GetContextFlags(ctx); if (!ctx->module->onload && @@ -4563,7 +4563,7 @@ RedisModuleKeyMetaClassId RM_CreateKeyMetaClass(RedisModuleCtx *ctx, if (!confPtr) return -2; - + /* This structure supposed to evolve over time and defines the superset of all * module type methods supported across different Redis module API versions */ struct KeyMetaConfAllVersions { @@ -4579,11 +4579,11 @@ RedisModuleKeyMetaClassId RM_CreateKeyMetaClass(RedisModuleCtx *ctx, KeyMetaLoadFunc rdb_load; KeyMetaSaveFunc rdb_save; KeyMetaAOFRewriteFunc aof_rewrite; - KeyMetaDefragFunc defrag; + KeyMetaDefragFunc defrag; KeyMetaMemUsageFunc mem_usage; KeyMetaFreeEffortFunc free_effort; } *legacy = (struct KeyMetaConfAllVersions *)confPtr; - + if (legacy->version == 0 || legacy->version > REDISMODULE_KEY_META_VERSION) return -3; @@ -4607,7 +4607,7 @@ RedisModuleKeyMetaClassId RM_CreateKeyMetaClass(RedisModuleCtx *ctx, id = keyMetaClassCreate(ctx->module, metaname, metaver, &conf); if (id == 0) return -4; - + return id; } @@ -4637,10 +4637,10 @@ int RM_SetKeyMeta(RedisModuleKeyMetaClassId id, RedisModuleKey *key, uint64_t me int RM_GetKeyMeta(RedisModuleKeyMetaClassId id, RedisModuleKey *key, uint64_t *metadata) { if ((!key) || (key->kv == NULL) || (!metadata)) return REDISMODULE_ERR; - + if (keyMetaGetMetadata(id, key->kv, metadata) == 0) return REDISMODULE_ERR; - + return REDISMODULE_OK; } @@ -5185,7 +5185,7 @@ int moduleZsetAddFlagsFromCoreFlags(int flags) { * * REDISMODULE_ZADD_XX: Element must already exist. Do nothing otherwise. * REDISMODULE_ZADD_NX: Element must not exist. Do nothing otherwise. - * REDISMODULE_ZADD_GT: If element exists, new score must be greater than the current score. + * REDISMODULE_ZADD_GT: If element exists, new score must be greater than the current score. * Do nothing otherwise. Can optionally be combined with XX. * REDISMODULE_ZADD_LT: If element exists, new score must be less than the current score. * Do nothing otherwise. Can optionally be combined with XX. @@ -5831,12 +5831,12 @@ int RM_HashSet(RedisModuleKey *key, int flags, ...) { * expecting a RedisModuleString pointer to pointer, the function just * reports if the field exists or not and expects an integer pointer * as the second element of each pair. - * + * * REDISMODULE_HASH_EXPIRE_TIME: retrieves the expiration time of a field in the hash. * The function expects a `mstime_t` pointer as the second element of each pair. - * If the field does not exist or has no expiration, the value is set to + * If the field does not exist or has no expiration, the value is set to * `REDISMODULE_NO_EXPIRE`. This flag must not be used with `REDISMODULE_HASH_EXISTS`. - * + * * Example of REDISMODULE_HASH_CFIELDS: * * RedisModuleString *username, *hashedpass; @@ -5849,9 +5849,9 @@ int RM_HashSet(RedisModuleKey *key, int flags, ...) { * * Example of REDISMODULE_HASH_EXPIRE_TIME: * - * mstime_t hpExpireTime; + * mstime_t hpExpireTime; * RedisModule_HashGet(mykey,REDISMODULE_HASH_EXPIRE_TIME,"hp",&hpExpireTime,NULL); - * + * * The function returns REDISMODULE_OK on success and REDISMODULE_ERR if * the key is not a hash value. * @@ -5869,8 +5869,8 @@ int RM_HashGet(RedisModuleKey *key, int flags, ...) { hfeFlags = HFE_LAZY_ACCESS_EXPIRED; /* allow read also expired fields */ /* Verify flag HASH_EXISTS is not set together with HASH_EXPIRE_TIME */ - if ((flags & REDISMODULE_HASH_EXISTS) && (flags & REDISMODULE_HASH_EXPIRE_TIME)) - return REDISMODULE_ERR; + if ((flags & REDISMODULE_HASH_EXISTS) && (flags & REDISMODULE_HASH_EXPIRE_TIME)) + return REDISMODULE_ERR; va_start(ap, flags); while(1) { @@ -5895,7 +5895,7 @@ int RM_HashGet(RedisModuleKey *key, int flags, ...) { *existsptr = 0; } } else if (flags & REDISMODULE_HASH_EXPIRE_TIME) { - mstime_t *expireptr = va_arg(ap,mstime_t*); + mstime_t *expireptr = va_arg(ap,mstime_t*); *expireptr = REDISMODULE_NO_EXPIRE; if (key->kv) { uint64_t expireTime = 0; @@ -5932,7 +5932,7 @@ int RM_HashGet(RedisModuleKey *key, int flags, ...) { /** * Retrieves the minimum expiration time of fields in a hash. - * + * * Return: * - The minimum expiration time (in milliseconds) of the hash fields if at * least one field has an expiration set. @@ -5942,7 +5942,7 @@ int RM_HashGet(RedisModuleKey *key, int flags, ...) { mstime_t RM_HashFieldMinExpire(RedisModuleKey *key) { if ((!key->kv) || (key->kv->type != OBJ_HASH)) return REDISMODULE_NO_EXPIRE; - + mstime_t min = hashTypeGetMinExpire(key->kv, 1); return (min == EB_EXPIRE_TIME_INVALID) ? REDISMODULE_NO_EXPIRE : min; } @@ -7367,7 +7367,7 @@ robj *moduleTypeDupOrReply(client *c, robj *fromkey, robj *tokey, int todb, robj } else { newval = mt->copy(fromkey, tokey, mv->value); } - + if (!newval) { addReplyError(c, "module key failed to copy"); return NULL; @@ -7417,7 +7417,7 @@ robj *moduleTypeDupOrReply(client *c, robj *fromkey, robj *tokey, int todb, robj * .unlink = myType_UnlinkCallBack, * .copy = myType_CopyCallback, * .defrag = myType_DefragCallback - * + * * // Enhanced optional fields * .mem_usage2 = myType_MemUsageCallBack2, * .free_effort2 = myType_FreeEffortCallBack2, @@ -7436,11 +7436,11 @@ robj *moduleTypeDupOrReply(client *c, robj *fromkey, robj *tokey, int todb, robj * Similar to aux_save, returns REDISMODULE_OK on success, and ERR otherwise. * * **free_effort**: A callback function pointer that used to determine whether the module's * memory needs to be lazy reclaimed. The module should return the complexity involved by - * freeing the value. for example: how many pointers are gonna be freed. Note that if it + * freeing the value. for example: how many pointers are gonna be freed. Note that if it * returns 0, we'll always do an async free. - * * **unlink**: A callback function pointer that used to notifies the module that the key has - * been removed from the DB by redis, and may soon be freed by a background thread. Note that - * it won't be called on FLUSHALL/FLUSHDB (both sync and async), and the module can use the + * * **unlink**: A callback function pointer that used to notifies the module that the key has + * been removed from the DB by redis, and may soon be freed by a background thread. Note that + * it won't be called on FLUSHALL/FLUSHDB (both sync and async), and the module can use the * RedisModuleEvent_FlushDB to hook into that. * * **copy**: A callback function pointer that is used to make a copy of the specified key. * The module is expected to perform a deep copy of the specified value and return it. @@ -7448,7 +7448,7 @@ robj *moduleTypeDupOrReply(client *c, robj *fromkey, robj *tokey, int todb, robj * A NULL return value is considered an error and the copy operation fails. * Note: if the target key exists and is being overwritten, the copy callback will be * called first, followed by a free callback to the value that is being replaced. - * + * * * **defrag**: A callback function pointer that is used to request the module to defrag * a key. The module should then iterate pointers and call the relevant RM_Defrag*() * functions to defragment pointers or complex types. The module should continue @@ -7476,7 +7476,7 @@ robj *moduleTypeDupOrReply(client *c, robj *fromkey, robj *tokey, int todb, robj * * **aux_save2**: Similar to `aux_save`, but with small semantic change, if the module * saves nothing on this callback then no data about this aux field will be written to the * RDB and it will be possible to load the RDB even if the module is not loaded. - * + * * Note: the module name "AAAAAAAAA" is reserved and produces an error, it * happens to be pretty lame as well. * @@ -8033,7 +8033,7 @@ void *RM_LoadDataTypeFromStringEncver(const RedisModuleString *str, const module void *ret; rioInitWithBuffer(&payload, str->ptr); - moduleType *mt_non_const = (moduleType *)mt; /*cast const away*/ + moduleType *mt_non_const = (moduleType *)mt; /*cast const away*/ moduleInitIOContext(&io, &mt_non_const->entity, &payload, NULL, -1); /* All RM_Save*() calls always write a version 2 compatible format, so we @@ -8048,7 +8048,7 @@ void *RM_LoadDataTypeFromStringEncver(const RedisModuleString *str, const module } /* Similar to RM_LoadDataTypeFromStringEncver, original version of the API, kept - * for backward compatibility. + * for backward compatibility. */ void *RM_LoadDataTypeFromString(const RedisModuleString *str, const moduleType *mt) { return RM_LoadDataTypeFromStringEncver(str, mt, 0); @@ -8999,7 +8999,7 @@ int moduleBlockedClientMayTimeout(client *c) { /* Called when our client timed out. After this function unblockClient() * is called, and it will invalidate the blocked client. So this function * does not need to do any cleanup. Eventually the module will call the - * API to unblock the client and the memory will be released. + * API to unblock the client and the memory will be released. * * This function should only be called from the main thread, we must handle the unblocking * of the client synchronously. This ensures that we can reply to the client before @@ -9603,6 +9603,14 @@ int RM_AddPostNotificationJobForKey(RedisModuleCtx *ctx, RedisModulePostNotifica return REDISMODULE_ERR; } + if (!callback || !key) { + serverLog(LL_WARNING, + "API misuse detected in module %s: " + "RedisModule_AddPostNotificationJobForKey called with NULL callback or key.", + ctx->module->name); + return REDISMODULE_ERR; + } + RedisModulePostKeyedNotificationJob *job = zmalloc(sizeof(*job)); job->module = ctx->module; job->callback = callback; @@ -10631,9 +10639,9 @@ int RM_FreeModuleUser(RedisModuleUser *user) { * The returned string must be freed by the caller with RedisModule_FreeString() * or by enabling automatic memory management on a context. */ RedisModuleString *RM_GetUserUsername(RedisModuleCtx *ctx, const RedisModuleUser *user) { - if(user == NULL || user->user == NULL || user->user->name == NULL) + if(user == NULL || user->user == NULL || user->user->name == NULL) return NULL; - + return RM_CreateString(ctx, user->user->name, sdslen(user->user->name)); } @@ -10765,13 +10773,13 @@ int RM_ACLCheckCommandPermissions(RedisModuleUser *user, RedisModuleString **arg * keyspec for logical operations. These flags are documented in RedisModule_SetCommandInfo as * the REDISMODULE_CMD_KEY_ACCESS, REDISMODULE_CMD_KEY_UPDATE, REDISMODULE_CMD_KEY_INSERT, * and REDISMODULE_CMD_KEY_DELETE flags. - * + * * If no flags are supplied, the user is still required to have some access to the key for * this command to return successfully. * * If the user is able to access the key then REDISMODULE_OK is returned, otherwise * REDISMODULE_ERR is returned and errno is set to one of the following values: - * + * * * EINVAL: The provided flags are invalid. * * EACCESS: The user does not have permission to access the key. */ @@ -10795,18 +10803,18 @@ int RM_ACLCheckKeyPermissions(RedisModuleUser *user, RedisModuleString *key, int return REDISMODULE_OK; } -/* Check if the user can access keys matching the given key prefix according to the ACLs - * attached to the user and the flags representing key access. The flags are the same that - * are used in the keyspec for logical operations. These flags are documented in - * RedisModule_SetCommandInfo as the REDISMODULE_CMD_KEY_ACCESS, +/* Check if the user can access keys matching the given key prefix according to the ACLs + * attached to the user and the flags representing key access. The flags are the same that + * are used in the keyspec for logical operations. These flags are documented in + * RedisModule_SetCommandInfo as the REDISMODULE_CMD_KEY_ACCESS, * REDISMODULE_CMD_KEY_UPDATE, REDISMODULE_CMD_KEY_INSERT, and REDISMODULE_CMD_KEY_DELETE flags. - * - * If no flags are supplied, the user is still required to have some access to keys matching + * + * If no flags are supplied, the user is still required to have some access to keys matching * the prefix for this command to return successfully. * * If the user is able to access keys matching the prefix, then REDISMODULE_OK is returned. * Otherwise, REDISMODULE_ERR is returned and errno is set to one of the following values: - * + * * * EINVAL: The provided flags are invalid. * * EACCES: The user does not have permission to access keys matching the prefix. */ @@ -10840,9 +10848,9 @@ int RM_ACLCheckKeyPrefixPermissions(RedisModuleUser *user, RedisModuleString *pr * * If the user is able to access the pubsub channel then REDISMODULE_OK is returned, otherwise * REDISMODULE_ERR is returned and errno is set to one of the following values: - * + * * * EINVAL: The provided flags are invalid. - * * EACCESS: The user does not have permission to access the pubsub channel. + * * EACCESS: The user does not have permission to access the pubsub channel. */ int RM_ACLCheckChannelPermissions(RedisModuleUser *user, RedisModuleString *ch, int flags) { const int allow_mask = (REDISMODULE_CMD_CHANNEL_PUBLISH @@ -11000,15 +11008,15 @@ int RM_DeauthenticateAndCloseClient(RedisModuleCtx *ctx, uint64_t client_id) { return REDISMODULE_OK; } -/* Redact the client command argument specified at the given position. Redacted arguments +/* Redact the client command argument specified at the given position. Redacted arguments * are obfuscated in user facing commands such as SLOWLOG or MONITOR, as well as * never being written to server logs. This command may be called multiple times on the * same position. - * - * Note that the command name, position 0, can not be redacted. - * - * Returns REDISMODULE_OK if the argument was redacted and REDISMODULE_ERR if there - * was an invalid parameter passed in or the position is outside the client + * + * Note that the command name, position 0, can not be redacted. + * + * Returns REDISMODULE_OK if the argument was redacted and REDISMODULE_ERR if there + * was an invalid parameter passed in or the position is outside the client * argument range. */ int RM_RedactClientCommandArgument(RedisModuleCtx *ctx, int pos) { if (!ctx || !ctx->client || pos <= 0 || ctx->client->argc <= pos) { @@ -12199,7 +12207,7 @@ static void moduleScanKeyCallback(void *privdata, const dictEntry *de, dictEntry field = createStringObject(fieldStr, sdslen(fieldStr)); value = createStringObjectFromLongDouble(znode->score, 0); } - + serverAssert(field != NULL); data->fn(data->key, field, value, data->user_data); decrRefCount(field); @@ -12687,7 +12695,7 @@ static uint64_t moduleEventVersions[] = { * int32_t dbnum_second; // Swap Db second dbnum * * * RedisModuleEvent_ReplBackup - * + * * WARNING: Replication Backup events are deprecated since Redis 7.0 and are never fired. * See RedisModuleEvent_ReplAsyncLoad for understanding how Async Replication Loading events * are now triggered when repl-diskless-load is set to swapdb. @@ -12702,7 +12710,7 @@ static uint64_t moduleEventVersions[] = { * * `REDISMODULE_SUBEVENT_REPL_BACKUP_CREATE` * * `REDISMODULE_SUBEVENT_REPL_BACKUP_RESTORE` * * `REDISMODULE_SUBEVENT_REPL_BACKUP_DISCARD` - * + * * * RedisModuleEvent_ReplAsyncLoad * * Called when repl-diskless-load config is set to swapdb and a replication with a master of same @@ -12744,7 +12752,7 @@ static uint64_t moduleEventVersions[] = { * structure with the following fields: * * const char **config_names; // An array of C string pointers containing the - * // name of each modified configuration item + * // name of each modified configuration item * uint32_t num_changes; // The number of elements in the config_names array * * * RedisModule_Event_Key @@ -12898,7 +12906,7 @@ int RM_IsSubEventSupported(RedisModuleEvent event, int64_t subevent) { case REDISMODULE_EVENT_EVENTLOOP: return subevent < _REDISMODULE_SUBEVENT_EVENTLOOP_NEXT; case REDISMODULE_EVENT_CONFIG: - return subevent < _REDISMODULE_SUBEVENT_CONFIG_NEXT; + return subevent < _REDISMODULE_SUBEVENT_CONFIG_NEXT; case REDISMODULE_EVENT_KEY: return subevent < _REDISMODULE_SUBEVENT_KEY_NEXT; case REDISMODULE_EVENT_CLUSTER_SLOT_MIGRATION: @@ -13080,7 +13088,7 @@ void moduleNotifyKeyUnlink(robj *key, kvobj *kv, int dbid, int flags) { server.allow_access_trimmed--; } -/* Return the free_effort of the module, it will automatically choose to call +/* Return the free_effort of the module, it will automatically choose to call * `free_effort` or `free_effort2`, and the default return value is 1. * value of 0 means very high effort (always asynchronous freeing). */ size_t moduleGetFreeEffort(robj *key, robj *val, int dbid) { @@ -13093,12 +13101,12 @@ size_t moduleGetFreeEffort(robj *key, robj *val, int dbid) { effort = mt->free_effort2(&ctx,mv->value); } else if (mt->free_effort != NULL) { effort = mt->free_effort(key,mv->value); - } + } return effort; } -/* Return the memory usage of the module, it will automatically choose to call +/* Return the memory usage of the module, it will automatically choose to call * `mem_usage` or `mem_usage2`, and the default return value is 0. */ size_t moduleGetMemUsage(robj *key, robj *val, size_t sample_size, int dbid) { moduleValue *mv = val->ptr; @@ -13110,7 +13118,7 @@ size_t moduleGetMemUsage(robj *key, robj *val, size_t sample_size, int dbid) { size = mt->mem_usage2(&ctx, mv->value, sample_size); } else if (mt->mem_usage != NULL) { size = mt->mem_usage(mv->value); - } + } return size; } @@ -13582,7 +13590,7 @@ int moduleOnLoad(int (*onload)(void *, void **, int), const char *path, void *ha /* Unload the module registered with the specified name. On success * C_OK is returned, otherwise C_ERR is returned and errmsg is set * with an appropriate message. - * Only forcefully unload this module, passing forced_unload != 0, + * Only forcefully unload this module, passing forced_unload != 0, * if it is certain that it has not yet been in use (e.g., immediate * unload on failed load). */ int moduleUnload(sds name, const char **errmsg, int forced_unload) { @@ -13765,7 +13773,7 @@ sds genModulesInfoString(sds info) { /* -------------------------------------------------------------------------- * Module Configurations API internals * -------------------------------------------------------------------------- */ - + /* Check if the configuration name is already registered */ int isModuleConfigNameRegistered(RedisModule *module, const char *name) { listNode *match = listSearchKey(module->module_configs, (void *) name); @@ -13819,11 +13827,11 @@ int moduleVerifyResourceName(const char *name) { return REDISMODULE_OK; } -/* Verify unprefixed name config might be a single "" or in the form - * "|". Unlike moduleVerifyResourceName(), unprefixed name config - * allows a single dot in the name or alias. - * - * delim - Updates to point to "|" if it exists, NULL otherwise. +/* Verify unprefixed name config might be a single "" or in the form + * "|". Unlike moduleVerifyResourceName(), unprefixed name config + * allows a single dot in the name or alias. + * + * delim - Updates to point to "|" if it exists, NULL otherwise. */ int moduleVerifyUnprefixedName(const char *nameAlias, const char **delim) { if (nameAlias[0] == '\0') @@ -13834,7 +13842,7 @@ int moduleVerifyUnprefixedName(const char *nameAlias, const char **delim) { for (size_t i = 0; nameAlias[i] != '\0'; i++) { char ch = nameAlias[i]; - + if (((*delim) == NULL) && (ch == '|')) { /* Handle single separator between name and alias */ if (!lname) { @@ -13849,7 +13857,7 @@ int moduleVerifyUnprefixedName(const char *nameAlias, const char **delim) { ++lname; } else if (ch == '.') { /* Allow only one dot per section (name or alias) */ - if (++dot_count > 1) { + if (++dot_count > 1) { serverLog(LL_WARNING, "Invalid character sequence in Module configuration name or alias: %s", nameAlias); return REDISMODULE_ERR; } @@ -13858,7 +13866,7 @@ int moduleVerifyUnprefixedName(const char *nameAlias, const char **delim) { return REDISMODULE_ERR; } } - + if (!lname) { serverLog(LL_WARNING, "Module configuration name or alias is empty : %s", nameAlias); return REDISMODULE_ERR; @@ -13867,7 +13875,7 @@ int moduleVerifyUnprefixedName(const char *nameAlias, const char **delim) { return REDISMODULE_OK; } -/* This is a series of set functions for each type that act as dispatchers for +/* This is a series of set functions for each type that act as dispatchers for * config.c to call module set callbacks. */ #define CONFIG_ERR_SIZE 256 static char configerr[CONFIG_ERR_SIZE]; @@ -13879,8 +13887,8 @@ static void propagateErrorString(RedisModuleString *err_in, const char **err) { } } -/* If configuration was originally registered with indication to prefix the name, - * return the name without the prefix by skipping prefix ".". +/* If configuration was originally registered with indication to prefix the name, + * return the name without the prefix by skipping prefix ".". * Otherwise, return the stored name as is. */ static char *getRegisteredConfigName(ModuleConfig *config) { if (config->unprefixedFlag) @@ -13888,7 +13896,7 @@ static char *getRegisteredConfigName(ModuleConfig *config) { /* For prefixed configuration, find the '.' indicating the end of the prefix */ char *endOfPrefix = strchr(config->name, '.'); - serverAssert(endOfPrefix != NULL); + serverAssert(endOfPrefix != NULL); return endOfPrefix + 1; } @@ -13904,7 +13912,7 @@ int setModuleBoolConfig(ModuleConfig *config, int val, const char **err) { int setModuleStringConfig(ModuleConfig *config, sds strval, const char **err) { RedisModuleString *error = NULL; RedisModuleString *new = createStringObject(strval, sdslen(strval)); - + char *rname = getRegisteredConfigName(config); int return_code = config->set_fn.set_string(rname, new, config->privdata, &error); propagateErrorString(error, err); @@ -13928,7 +13936,7 @@ int setModuleNumericConfig(ModuleConfig *config, long long val, const char **err return return_code == REDISMODULE_OK ? 1 : 0; } -/* This is a series of get functions for each type that act as dispatchers for +/* This is a series of get functions for each type that act as dispatchers for * config.c to call module set callbacks. */ int getModuleBoolConfig(ModuleConfig *module_config) { char *rname = getRegisteredConfigName(module_config); @@ -14067,19 +14075,19 @@ int moduleConfigApplyConfig(list *module_configs, const char **err, const char * * -------------------------------------------------------------------------- */ /* Resolve config name and create a module config object */ -ModuleConfig *createModuleConfig(const char *name, RedisModuleConfigApplyFunc apply_fn, - void *privdata, RedisModule *module, unsigned int flags) +ModuleConfig *createModuleConfig(const char *name, RedisModuleConfigApplyFunc apply_fn, + void *privdata, RedisModule *module, unsigned int flags) { sds cname, alias = NULL; /* Determine the configuration name: * - If the unprefixed flag is set, the "." prefix is omitted. * - An optional alias can be specified using "|". - * + * * Examples: * - Unprefixed: "bf.initial_size" or "bf-initial-size|bf.initial_size". * - Prefixed: "initial_size" becomes ".initial_size". - */ + */ if (flags & REDISMODULE_CONFIG_UNPREFIXED) { const char *delim = strchr(name, '|'); cname = sdsnew(name); @@ -14091,7 +14099,7 @@ ModuleConfig *createModuleConfig(const char *name, RedisModuleConfigApplyFunc ap /* Add the module name prefix */ cname = sdscatfmt(sdsempty(), "%s.%s", module->name, name); } - + ModuleConfig *new_config = zmalloc(sizeof(ModuleConfig)); new_config->unprefixedFlag = flags & REDISMODULE_CONFIG_UNPREFIXED; new_config->name = cname; @@ -14103,7 +14111,7 @@ ModuleConfig *createModuleConfig(const char *name, RedisModuleConfigApplyFunc ap } /* Verify the configuration name and check for duplicates. - * + * * - If the configuration is flagged as unprefixed, it checks for duplicate * names and optional aliases in the format |. * - If the configuration is prefixed, it ensures the name is unique with @@ -14118,22 +14126,22 @@ int moduleConfigValidityCheck(RedisModule *module, const char *name, unsigned in errno = EINVAL; return REDISMODULE_ERR; } - - int isdup = 0; + + int isdup = 0; if (flags & REDISMODULE_CONFIG_UNPREFIXED) { const char *delim = NULL; /* Pointer to the '|' delimiter in | */ if (moduleVerifyUnprefixedName(name, &delim)){ errno = EINVAL; return REDISMODULE_ERR; } - - if (delim) { + + if (delim) { /* Temporary split the "|" for the check */ int count; sds *ar = sdssplitlen(name, strlen(name), "|", 1, &count); serverAssert(count == 2); /* Already validated */ - isdup = configExists(ar[0]) || - configExists(ar[1]) || + isdup = configExists(ar[0]) || + configExists(ar[1]) || (sdscmp(ar[0], ar[1]) == 0); sdsfreesplitres(ar, count); } else { @@ -14151,7 +14159,7 @@ int moduleConfigValidityCheck(RedisModule *module, const char *name, unsigned in isdup = configExists(fullname); sdsfree(fullname); } - + if (isdup) { serverLog(LL_WARNING, "Configuration by the name: %s already registered", name); errno = EALREADY; @@ -14262,19 +14270,19 @@ int RM_RegisterStringConfig(RedisModuleCtx *ctx, const char *name, const char *d if (moduleConfigValidityCheck(module, name, flags, NUMERIC_CONFIG)) { return REDISMODULE_ERR; } - + ModuleConfig *mc = createModuleConfig(name, applyfn, privdata, module, flags); mc->get_fn.get_string = getfn; mc->set_fn.set_string = setfn; listAddNodeTail(module->module_configs, mc); unsigned int cflags = maskModuleConfigFlags(flags); - addModuleStringConfig(sdsdup(mc->name), (mc->alias) ? sdsdup(mc->alias) : NULL, + addModuleStringConfig(sdsdup(mc->name), (mc->alias) ? sdsdup(mc->alias) : NULL, cflags, mc, default_val ? sdsnew(default_val) : NULL); return REDISMODULE_OK; } -/* Create a bool config that server clients can interact with via the - * `CONFIG SET`, `CONFIG GET`, and `CONFIG REWRITE` commands. See +/* Create a bool config that server clients can interact with via the + * `CONFIG SET`, `CONFIG GET`, and `CONFIG REWRITE` commands. See * RedisModule_RegisterStringConfig for detailed information about configs. */ int RM_RegisterBoolConfig(RedisModuleCtx *ctx, const char *name, int default_val, unsigned int flags, RedisModuleConfigGetBoolFunc getfn, RedisModuleConfigSetBoolFunc setfn, RedisModuleConfigApplyFunc applyfn, void *privdata) { RedisModule *module = ctx->module; @@ -14286,15 +14294,15 @@ int RM_RegisterBoolConfig(RedisModuleCtx *ctx, const char *name, int default_val mc->set_fn.set_bool = setfn; listAddNodeTail(module->module_configs, mc); unsigned int cflags = maskModuleConfigFlags(flags); - addModuleBoolConfig(sdsdup(mc->name), (mc->alias) ? sdsdup(mc->alias) : NULL, + addModuleBoolConfig(sdsdup(mc->name), (mc->alias) ? sdsdup(mc->alias) : NULL, cflags, mc, default_val); return REDISMODULE_OK; } -/* - * Create an enum config that server clients can interact with via the - * `CONFIG SET`, `CONFIG GET`, and `CONFIG REWRITE` commands. - * Enum configs are a set of string tokens to corresponding integer values, where +/* + * Create an enum config that server clients can interact with via the + * `CONFIG SET`, `CONFIG GET`, and `CONFIG REWRITE` commands. + * Enum configs are a set of string tokens to corresponding integer values, where * the string value is exposed to Redis clients but the value passed Redis and the * module is the integer value. These values are defined in enum_values, an array * of null-terminated c strings, and int_vals, an array of enum values who has an @@ -14307,7 +14315,7 @@ int RM_RegisterBoolConfig(RedisModuleCtx *ctx, const char *name, int default_val * int getEnumConfigCommand(const char *name, void *privdata) { * return enum_val; * } - * + * * int setEnumConfigCommand(const char *name, int val, void *privdata, const char **err) { * enum_val = val; * return REDISMODULE_OK; @@ -14338,14 +14346,14 @@ int RM_RegisterEnumConfig(RedisModuleCtx *ctx, const char *name, int default_val listAddNodeTail(module->module_configs, mc); unsigned int cflags = maskModuleConfigFlags(flags) | maskModuleEnumConfigFlags(flags); - addModuleEnumConfig(sdsdup(mc->name), (mc->alias) ? sdsdup(mc->alias) : NULL, + addModuleEnumConfig(sdsdup(mc->name), (mc->alias) ? sdsdup(mc->alias) : NULL, cflags, mc, default_val, enum_vals, num_enum_vals); return REDISMODULE_OK; } /* - * Create an integer config that server clients can interact with via the - * `CONFIG SET`, `CONFIG GET`, and `CONFIG REWRITE` commands. See + * Create an integer config that server clients can interact with via the + * `CONFIG SET`, `CONFIG GET`, and `CONFIG REWRITE` commands. See * RedisModule_RegisterStringConfig for detailed information about configs. */ int RM_RegisterNumericConfig(RedisModuleCtx *ctx, const char *name, long long default_val, unsigned int flags, long long min, long long max, RedisModuleConfigGetNumericFunc getfn, RedisModuleConfigSetNumericFunc setfn, RedisModuleConfigApplyFunc applyfn, void *privdata) { RedisModule *module = ctx->module; @@ -14359,7 +14367,7 @@ int RM_RegisterNumericConfig(RedisModuleCtx *ctx, const char *name, long long de unsigned int numeric_flags = maskModuleNumericConfigFlags(flags); unsigned int cflags = maskModuleConfigFlags(flags); - addModuleNumericConfig(sdsdup(mc->name), (mc->alias) ? sdsdup(mc->alias) : NULL, + addModuleNumericConfig(sdsdup(mc->name), (mc->alias) ? sdsdup(mc->alias) : NULL, cflags, mc, default_val, numeric_flags, min, max); return REDISMODULE_OK; } @@ -14867,7 +14875,7 @@ NULL argc = c->argc - 3; argv = &c->argv[3]; } - /* If this is a loadex command we want to populate server.module_configs_queue with + /* If this is a loadex command we want to populate server.module_configs_queue with * sds NAME VALUE pairs. We also want to increment argv to just after ARGS, if supplied. */ if (parseLoadexArguments((RedisModuleString ***) &argv, &argc) == REDISMODULE_OK && moduleLoad(c->argv[2]->ptr, (void **)argv, argc, 1) == C_OK) @@ -15272,8 +15280,8 @@ void *RM_DefragAlloc(RedisModuleDefragCtx *ctx, void *ptr) { * owner. For such usecase RM_DefragAlloc is enough. But on some usecases the user * might want to replace a pointer with multiple owners in different keys. * In such case, an in place replacement can not work because the other key still - * keep a pointer to the old value. - * + * keep a pointer to the old value. + * * RM_DefragAllocRaw and RM_DefragFreeRaw allows to control when the memory * for defrag purposes will be allocated and when it will be freed, * allow to support more complex defrag usecases. */ @@ -15283,7 +15291,7 @@ void *RM_DefragAllocRaw(RedisModuleDefragCtx *ctx, size_t size) { } /* Free memory for defrag purposes - * + * * See RM_DefragAllocRaw for more information. */ void RM_DefragFreeRaw(RedisModuleDefragCtx *ctx, void *ptr) { UNUSED(ctx); @@ -15459,7 +15467,7 @@ int moduleDefragValue(robj *key, robj *value, int dbid) { /* Call registered module API defrag start functions */ void moduleDefragStart(void) { - dictForEach(modules, struct RedisModule, module, + dictForEach(modules, struct RedisModule, module, if (module->defrag_start_cb) { RedisModuleDefragCtx defrag_ctx = INIT_MODULE_DEFRAG_CTX(0, NULL, NULL, -1); module->defrag_start_cb(&defrag_ctx); @@ -15469,7 +15477,7 @@ void moduleDefragStart(void) { /* Call registered module API defrag end functions */ void moduleDefragEnd(void) { - dictForEach(modules, struct RedisModule, module, + dictForEach(modules, struct RedisModule, module, if (module->defrag_end_cb) { RedisModuleDefragCtx defrag_ctx = INIT_MODULE_DEFRAG_CTX(0, NULL, NULL, -1); module->defrag_end_cb(&defrag_ctx); diff --git a/tests/unit/moduleapi/postnotifications.tcl b/tests/unit/moduleapi/postnotifications.tcl index 924701651..012ce3479 100644 --- a/tests/unit/moduleapi/postnotifications.tcl +++ b/tests/unit/moduleapi/postnotifications.tcl @@ -78,7 +78,7 @@ foreach api {regular perkey} { [r keys expired] == {expired} } else { puts [r keys *] - fail "Failed waiting for x to expired" + fail "Failed waiting for x to expire" } # {lpush before_expired x} comes from the RedisModuleEvent_Key