Files
valkey/tests/unit/moduleapi/commandresult.tcl
T
martinrvisser 6444717517 Module command result callback addition (#2936)
## Add Command Result Event Notifications for Modules

### Summary

1. Adds new server events `ValkeyModuleEvent_CommandResultSuccess` and
`ValkeyModuleEvent_CommandResultFailure` for that can notify subscribed
modules after command execution. This enables modules to implement audit
logging, error monitoring, performance tracking, and observability
without modifying core server code.
2. Adds new server event `ValkeyModuleEvent_CommandResultACLDenied` for
commands rejected by ACL. Together with PR #2237 this covers auditing of
authentication and authorisation.

### Motivation

There is currently no module API to observe command outcomes after
execution or to capture ACL denied commands. Modules that need audit
logging or error monitoring have no mechanism to be notified when
commands succeed or fail, what arguments were used, how long they took,
or how many keys were modified. This feature fills that gap using the
existing `ValkeyModule_SubscribeToServerEvent()` infrastructure.

### API

#### Events

| Event | Description |
|---|---|
| `ValkeyModuleEvent_CommandResultSuccess` | Fired after a command
completes successfully |
| `ValkeyModuleEvent_CommandResultFailure` | Fired after a command
returns an error |
| `ValkeyModuleEvent_CommandACLDenied` | Fired after a command is
rejected by ACL |

These are separate events (not sub-events), so modules can for example
only subscribe to failures without incurring any callback overhead for
successful commands.

#### Event Data: `ValkeyModuleCommandResultInfo`

The `data` pointer passed to the callback can be cast to
`ValkeyModuleCommandResultInfo`:

```c
typedef struct ValkeyModuleCommandResultInfo {
    uint64_t version;           /* Version of this structure for ABI compat. */
    const char *command_name;   /* Full command name (e.g., "SET", "CLIENT|LIST"). */
    long long duration_us;      /* Execution duration in microseconds. */
    long long dirty;            /* Number of keys modified. */
    uint64_t client_id;         /* Client ID that executed the command. */
    int is_module_client;       /* 1 if command was from RM_Call, 0 otherwise. */
    int argc;                   /* Number of command arguments. */
    ValkeyModuleString **argv;  /* Command arguments array (zero-copy, read-only). */
    int acl_deny_reason;        /* ACL_DENIED_CMD/KEY/CHANNEL/AUTH; 0 for non-ACL events */
    const char *acl_object;     /* Denied resource name (key/channel); NULL for CMD/AUTH */
} ValkeyModuleCommandResultInfoV1;
```

The struct is versioned (`VALKEYMODULE_COMMANDRESULTINFO_VERSION`) for
forward-compatible API evolution.

### Usage Example

```c
/* Callback receives events for whichever event(s) you subscribed to */
void OnCommandResult(ValkeyModuleCtx *ctx, ValkeyModuleEvent eid,
                     uint64_t subevent, void *data) {
    VALKEYMODULE_NOT_USED(ctx);
    VALKEYMODULE_NOT_USED(subevent);

    ValkeyModuleCommandResultInfo *info = (ValkeyModuleCommandResultInfo *)data;
    if (info->version != VALKEYMODULE_COMMANDRESULTINFO_VERSION) return;

    int failed = (eid.id == VALKEYMODULE_EVENT_COMMAND_RESULT_FAILURE);

    /* Access fields directly */
    printf("command=%s status=%s duration=%lldus dirty=%lld client=%llu\n",
           info->command_name,
           failed ? "FAIL" : "OK",
           info->duration_us,
           info->dirty,
           info->client_id);

    /* Access argv (read-only, zero-copy) */
    for (int i = 0; i < info->argc; i++) {
        size_t len;
        const char *arg = ValkeyModule_StringPtrLen(info->argv[i], &len);
        printf("  argv[%d] = %.*s\n", i, (int)len, arg);
    }
}

/* Subscribe in ValkeyModule_OnLoad or at runtime */

/* Option A: command failures only (recommended for audit logging) */
ValkeyModule_SubscribeToServerEvent(ctx,
    ValkeyModuleEvent_CommandResultFailure, OnCommandResult);

/* Option B: command successes only */
ValkeyModule_SubscribeToServerEvent(ctx,
    ValkeyModuleEvent_CommandResultSuccess, OnCommandResult);

/* Option C: both command outcomes*/
ValkeyModule_SubscribeToServerEvent(ctx,
    ValkeyModuleEvent_CommandResultSuccess, OnCommandResult);
ValkeyModule_SubscribeToServerEvent(ctx,
    ValkeyModuleEvent_CommandResultFailure, OnCommandResult);

/* Subscribe to ACL Denied */
ValkeyModule_SubscribeToServerEvent(ctx,
        ValkeyModuleEvent_CommandResultACLDenied, onCommandResult);

/* Unsubscribe pass NULL callback */
ValkeyModule_SubscribeToServerEvent(ctx,
    ValkeyModuleEvent_CommandResultFailure, NULL);
```

### Design Decisions

- **Separate events instead of sub-events**: Modules subscribing only to
failures have zero overhead for successful commands (~2ns listener-list
check vs ~30ns callback invocation per command). This is critical since
success events fire on the hot path of every command.
- **Stack-allocated info struct**: The `ValkeyModuleCommandResultInfoV1`
is built on the stack ΓÇö no heap allocation per event.
- **Zero-copy argv**: Arguments are passed directly from the client's
argv array. Any integer-encoded arguments (from `tryObjectEncoding()`
during command execution) are decoded to string-encoded objects before
being passed to the callback, ensuring compatibility with
`ValkeyModule_StringPtrLen()`.
- **Early exit**: If no modules are subscribed to any server events, the
event firing function returns immediately before building the info
struct.
- **Uses existing server event infrastructure**: Follows the
`ValkeyModule_SubscribeToServerEvent()` pattern used by all other server
events, rather than introducing a new callback mechanism.

### Files Changed

| File | Change |
|---|---|
| `src/valkeymodule.h` | Event IDs, event constants,
`ValkeyModuleCommandResultInfoV1` struct |
| `src/module.c` | `moduleFireCommandResultEvent()`, event
documentation, event version entries |
| `src/module.h` | Function declaration |
| `src/server.c` | Call `moduleFireCommandResultEvent()` from `call()`
after command execution |
| `src/server.c` | Call to `moduleFireCommandACLDeniedEvent` in
`processCommand` after ACL rejection |
| `tests/modules/commandresult.c` | Test module exercising the full API
|
| `tests/unit/moduleapi/commandresult.tcl` | Integration tests |

---------

Signed-off-by: martinrvisser <mvisser@hotmail.com>
Signed-off-by: martinrvisser <martinrvisser@users.noreply.github.com>
Co-authored-by: Ricardo Dias <rjd15372@gmail.com>
2026-04-21 09:14:14 -04:00

704 lines
21 KiB
Tcl

set testmodule [file normalize tests/modules/commandresult.so]
start_server {tags {"modules"}} {
r module load $testmodule
# Helper to ensure cleanup between tests
proc cleanup_callback {} {
catch {r cmdresult.unsubscribe}
r cmdresult.reset
}
test {Module commandresult - Subscribe to all command result events} {
cleanup_callback
r cmdresult.register all
# Execute some commands
r cmdresult.success
r ping
catch {r cmdresult.fail} e
# Check stats
set stats [r cmdresult.stats]
assert {[dict get $stats total_callbacks] >= 3}
assert {[dict get $stats success_count] >= 2}
assert {[dict get $stats failure_count] >= 1}
r cmdresult.unsubscribe
}
test {Module commandresult - Subscribe to success events only} {
cleanup_callback
r cmdresult.register success
# Execute successful and failing commands
r cmdresult.success
r ping
r cmdresult.success
catch {r cmdresult.fail} e
catch {r cmdresult.fail} e
# With success-only subscription, only success events are received
set stats [r cmdresult.stats]
assert {[dict get $stats success_count] >= 3}
# Failures should NOT be tracked since we only subscribed to success
assert_equal [dict get $stats failure_count] 0
r cmdresult.unsubscribe
}
test {Module commandresult - Subscribe to failure events only} {
cleanup_callback
r cmdresult.register failure
# Execute successful and failing commands
r cmdresult.success
r ping
r cmdresult.success
catch {r cmdresult.fail} e
catch {r cmdresult.fail} e
# With failure-only subscription, only failure events are received
set stats [r cmdresult.stats]
assert_equal [dict get $stats failure_count] 2
# Successes should NOT be tracked since we only subscribed to failure
assert_equal [dict get $stats success_count] 0
r cmdresult.unsubscribe
}
test {Module commandresult - Callback tracks duration} {
cleanup_callback
r cmdresult.register all
r cmdresult.success
r ping
set stats [r cmdresult.stats]
# Duration should be > 0 microseconds
assert {[dict get $stats total_duration_us] > 0}
r cmdresult.unsubscribe
}
test {Module commandresult - Callback tracks dirty keys} {
cleanup_callback
r cmdresult.register all
# This command modifies a key
r SET ss 3
set stats [r cmdresult.stats]
# Should have at least 1 dirty key
assert {[dict get $stats total_dirty] >= 1}
r cmdresult.unsubscribe
}
test {Module commandresult - Get command log} {
cleanup_callback
r cmdresult.register all
r cmdresult.success
catch {r cmdresult.fail} e
r ping
set log [r cmdresult.getlog 3]
assert_equal [llength $log] 3
# Check first entry (most recent - ping)
set entry [lindex $log 0]
assert {[dict get $entry command] eq "ping"}
assert {[dict get $entry status] eq "success"}
# Check second entry (cmdresult.fail)
set entry [lindex $log 1]
assert {[dict get $entry command] eq "cmdresult.fail"}
assert {[dict get $entry status] eq "failure"}
# Check third entry (cmdresult.success)
set entry [lindex $log 2]
assert {[dict get $entry command] eq "cmdresult.success"}
assert {[dict get $entry status] eq "success"}
r cmdresult.unsubscribe
}
test {Module commandresult - Client ID is captured} {
cleanup_callback
r cmdresult.register all
r cmdresult.success
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
# Client ID should be a positive integer
assert {[dict get $entry client_id] > 0}
r cmdresult.unsubscribe
}
test {Module commandresult - is_module_client detection} {
cleanup_callback
r cmdresult.register all
# Direct command - should NOT be from module client
r ping
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
assert_equal [dict get $entry is_module_client] 0
r cmdresult.unsubscribe
}
test {Module commandresult - RM_Call shows is_module_client=1} {
cleanup_callback
r cmdresult.register all
# This command calls PING via RM_Call
r cmdresult.rmcall ping
set log [r cmdresult.getlog 2]
# The inner ping should have is_module_client=1
set ping_entry [lindex $log 1]
assert {[dict get $ping_entry command] eq "ping"}
assert_equal [dict get $ping_entry is_module_client] 1
r cmdresult.unsubscribe
}
test {Module commandresult - Unsubscribe} {
cleanup_callback
r cmdresult.register all
r cmdresult.success
# Unsubscribe also triggers a callback before unsubscribing
r cmdresult.unsubscribe
# After unsubscribe, new commands shouldn't trigger callbacks
r cmdresult.success
r ping
set stats [r cmdresult.stats]
# Should have 2 callbacks (cmdresult.success + cmdresult.unsubscribe)
assert_equal [dict get $stats total_callbacks] 2
# Trying to unsubscribe again should fail
catch {r cmdresult.unsubscribe} err
assert_match {*not subscribed*} $err
}
test {Module commandresult - Cannot subscribe twice} {
cleanup_callback
r cmdresult.register all
# Trying to subscribe again should fail
catch {r cmdresult.register all} err
assert_match {*already subscribed*} $err
r cmdresult.unsubscribe
}
test {Module commandresult - Reset clears stats and log} {
cleanup_callback
r cmdresult.register all
r cmdresult.success
r ping
catch {r cmdresult.fail} e
# Verify we have stats
set stats [r cmdresult.stats]
assert {[dict get $stats total_callbacks] > 0}
# Reset should clear everything
cleanup_callback
set stats [r cmdresult.stats]
assert_equal [dict get $stats total_callbacks] 0
assert_equal [dict get $stats success_count] 0
assert_equal [dict get $stats failure_count] 0
assert_equal [dict get $stats rejected_count] 0
assert_equal [dict get $stats acl_denied_count] 0
set log [r cmdresult.getlog]
assert_equal [llength $log] 0
}
test {Module commandresult - Invalid mode returns error} {
cleanup_callback
catch {r cmdresult.register invalid_mode} err
assert_match {*invalid mode*} $err
}
test {Module commandresult - Command name is captured correctly} {
cleanup_callback
r cmdresult.register all
r cmdresult.success
r set mykey myvalue
r get mykey
set log [r cmdresult.getlog 3]
# Check that command names are correct
set commands [list]
foreach entry $log {
lappend commands [dict get $entry command]
}
assert {[lsearch $commands "get"] >= 0}
assert {[lsearch $commands "set"] >= 0}
assert {[lsearch $commands "cmdresult.success"] >= 0}
r cmdresult.unsubscribe
}
test {Module commandresult - Unload with active subscription} {
cleanup_callback
r cmdresult.register all
# Execute some commands to ensure callback is active
r cmdresult.success
r ping
set stats [r cmdresult.stats]
assert {[dict get $stats total_callbacks] >= 2}
# Unload module while subscription is still active
assert_equal {OK} [r module unload commandresult]
# Reload module for remaining tests
r module load $testmodule
}
test {Module commandresult - Multiple callbacks from different operations} {
cleanup_callback
r cmdresult.register all
# Test callbacks from various sources
r set testkey testvalue ;# Built-in command
r get testkey ;# Built-in command
r cmdresult.success ;# Module command
catch {r cmdresult.fail} e ;# Failing module command
set stats [r cmdresult.stats]
# Should have at least 4 callbacks
assert {[dict get $stats total_callbacks] >= 4}
assert {[dict get $stats success_count] >= 3}
assert {[dict get $stats failure_count] >= 1}
r cmdresult.unsubscribe
}
test {Module commandresult - Empty subscription optimization} {
cleanup_callback
# No subscription - this tests early return when no modules subscribed
# Execute commands without any subscriptions
r cmdresult.success
r ping
# Verify no callbacks were fired
set stats [r cmdresult.stats]
assert_equal [dict get $stats total_callbacks] 0
}
test {Module commandresult - Duration is always positive} {
cleanup_callback
r cmdresult.register all
# Execute a command
r cmdresult.success
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
# Duration should be >= 0 microseconds
assert {[dict get $entry duration_us] >= 0}
r cmdresult.unsubscribe
}
test {Module commandresult - RM_Call creates nested command execution} {
cleanup_callback
r cmdresult.register all
# cmdresult.rmcall calls PING via RM_Call
# Both the wrapper and inner command should be tracked
r cmdresult.rmcall ping
set stats [r cmdresult.stats]
# Should see both cmdresult.rmcall and ping callbacks
assert {[dict get $stats total_callbacks] >= 2}
r cmdresult.unsubscribe
}
test {Module commandresult - argv captures command arguments} {
cleanup_callback
r cmdresult.register all
# Execute a command with arguments
r set mykey myvalue
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
# Check argv is captured
set argv [dict get $entry argv]
assert_equal [lindex $argv 0] "set"
assert_equal [lindex $argv 1] "mykey"
assert_equal [lindex $argv 2] "myvalue"
r cmdresult.unsubscribe
}
test {Module commandresult - argv captures multi-argument commands} {
cleanup_callback
r cmdresult.register all
# Execute MSET with multiple key-value pairs
r mset key1 val1 key2 val2 key3 val3
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
set argv [dict get $entry argv]
assert_equal [lindex $argv 0] "mset"
assert_equal [lindex $argv 1] "key1"
assert_equal [lindex $argv 2] "val1"
assert_equal [lindex $argv 3] "key2"
assert_equal [lindex $argv 4] "val2"
r cmdresult.unsubscribe
}
test {Module commandresult - argv with command rewriting} {
cleanup_callback
r cmdresult.register all
# EXPIRE gets rewritten to PEXPIREAT internally
# But we should see original argv (EXPIRE)
r set rewritekey "value"
r expire rewritekey 100
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
set argv [dict get $entry argv]
# Should see original command, not rewritten one
assert_equal [string tolower [lindex $argv 0]] "expire"
assert_equal [lindex $argv 1] "rewritekey"
assert_equal [lindex $argv 2] "100"
r cmdresult.unsubscribe
}
test {Module commandresult - High volume command tracking} {
cleanup_callback
r cmdresult.register all
# Run many commands
for {set i 0} {$i < 100} {incr i} {
r ping
}
set stats [r cmdresult.stats]
assert {[dict get $stats total_callbacks] >= 100}
r cmdresult.unsubscribe
}
test {Module commandresult - acl_rejected: command not permitted (ACL_DENIED_CMD)} {
cleanup_callback
r cmdresult.register acl_rejected
# Create a user with no command permissions and authenticate
r acl setuser testuser_cmd on >testpass nocommands
set rd [valkey_deferring_client]
$rd auth testuser_cmd testpass
$rd read
$rd get somekey
catch {$rd read} e
$rd close
set stats [r cmdresult.stats]
assert {[dict get $stats acl_denied_count] >= 1}
assert_equal [dict get $stats success_count] 0
assert_equal [dict get $stats failure_count] 0
assert_equal [dict get $stats rejected_count] 0
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
assert_equal [dict get $entry status] "acl_rejected"
# VALKEYMODULE_ACL_LOG_CMD = 1
assert_equal [dict get $entry subevent] 1
assert_equal [dict get $entry rejection_context] ""
r acl deluser testuser_cmd
r cmdresult.unsubscribe
}
test {Module commandresult - acl_rejected: key pattern not permitted (ACL_DENIED_KEY)} {
cleanup_callback
r cmdresult.register acl_rejected
# Create a user allowed to run GET but only on keys matching "allowed:*"
r acl setuser testuser_key on >testpass allcommands ~allowed:* nopass
set rd [valkey_deferring_client]
$rd auth testuser_key testpass
$rd read
$rd get denied_key
catch {$rd read} e
$rd close
set stats [r cmdresult.stats]
assert {[dict get $stats acl_denied_count] >= 1}
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
assert_equal [dict get $entry status] "acl_rejected"
# VALKEYMODULE_ACL_LOG_KEY = 2
assert_equal [dict get $entry subevent] 2
assert_equal [dict get $entry rejection_context] "denied_key"
r acl deluser testuser_key
r cmdresult.unsubscribe
}
test {Module commandresult - acl_rejected: channel not permitted (ACL_DENIED_CHANNEL)} {
cleanup_callback
r cmdresult.register acl_rejected
# Create a user with allcommands but no pub/sub channel access
r acl setuser testuser_chan on >testpass allcommands allkeys resetchannels nopass
set rd [valkey_deferring_client]
$rd auth testuser_chan testpass
$rd read
$rd subscribe secret_channel
catch {$rd read} e
$rd close
set stats [r cmdresult.stats]
assert {[dict get $stats acl_denied_count] >= 1}
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
assert_equal [dict get $entry status] "acl_rejected"
# VALKEYMODULE_ACL_LOG_CHANNEL = 3
assert_equal [dict get $entry subevent] 3
assert_equal [dict get $entry rejection_context] "secret_channel"
r acl deluser testuser_chan
r cmdresult.unsubscribe
}
test {Module commandresult - acl_rejected events not fired when not subscribed} {
cleanup_callback
r cmdresult.register failure
r acl setuser testuser_nosub on >testpass nocommands nopass
set rd [valkey_deferring_client]
$rd auth testuser_nosub testpass
$rd read
$rd get somekey
catch {$rd read} e
$rd close
set stats [r cmdresult.stats]
assert_equal [dict get $stats acl_denied_count] 0
assert_equal [dict get $stats rejected_count] 0
r acl deluser testuser_nosub
r cmdresult.unsubscribe
}
test {Module commandresult - acl_rejected: unauthenticated command (NOAUTH)} {
cleanup_callback
r cmdresult.register acl_rejected
# Enable password so new connections require authentication
r config set requirepass testpass
# Open a raw unauthenticated connection and send a command without AUTH
set rd [valkey_deferring_client_by_addr [srv 0 host] [srv 0 port]]
$rd get somekey
catch {$rd read} e
$rd close
# Restore: the existing r session stays authenticated; just clear the password
r config set requirepass ""
set stats [r cmdresult.stats]
assert {[dict get $stats acl_denied_count] >= 1}
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
assert_equal [dict get $entry status] "acl_rejected"
# VALKEYMODULE_ACL_LOG_AUTH = 0
assert_equal [dict get $entry subevent] 0
assert_equal [dict get $entry rejection_context] ""
r cmdresult.unsubscribe
}
test {Module commandresult - Reset clears acl_denied_count} {
cleanup_callback
r cmdresult.register acl_rejected
r acl setuser testuser_reset on >testpass nocommands nopass
set rd [valkey_deferring_client]
$rd auth testuser_reset testpass
$rd read
$rd get somekey
catch {$rd read} e
$rd close
set stats [r cmdresult.stats]
assert {[dict get $stats acl_denied_count] >= 1}
r cmdresult.reset
set stats [r cmdresult.stats]
assert_equal [dict get $stats acl_denied_count] 0
assert_equal [dict get $stats rejected_count] 0
assert_equal [dict get $stats total_callbacks] 0
r acl deluser testuser_reset
r cmdresult.unsubscribe
}
test {Module commandresult - rejected: unknown command (UNKNOWNCMD)} {
cleanup_callback
r cmdresult.register rejected
catch {r thisdoesnotexist} e
set stats [r cmdresult.stats]
assert {[dict get $stats rejected_count] >= 1}
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
assert_equal [dict get $entry status] "rejected"
assert_match {*unknown*command*} [string tolower [dict get $entry rejection_context]]
r cmdresult.unsubscribe
}
test {Module commandresult - rejected: wrong number of arguments (WRONGARITY)} {
cleanup_callback
r cmdresult.register rejected
catch {r set} e
set stats [r cmdresult.stats]
assert {[dict get $stats rejected_count] >= 1}
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
assert_equal [dict get $entry status] "rejected"
assert_equal [dict get $entry command] "set"
assert_match {*wrong*number*arguments*} [string tolower [dict get $entry rejection_context]]
r cmdresult.unsubscribe
}
test {Module commandresult - rejected: command not allowed in MULTI (NOMULTI)} {
cleanup_callback
r cmdresult.register rejected
r multi
catch {r multi} e
r discard
set stats [r cmdresult.stats]
assert {[dict get $stats rejected_count] >= 1}
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
assert_equal [dict get $entry status] "rejected"
assert_equal [dict get $entry command] "multi"
assert_match {*not allowed inside a transaction*} [string tolower [dict get $entry rejection_context]]
r cmdresult.unsubscribe
}
test {Module commandresult - rejected: command not allowed in Pub/Sub context (PUBSUB)} {
cleanup_callback
r cmdresult.register rejected
set rd [valkey_deferring_client]
$rd subscribe testchan
$rd read
$rd set foo bar
catch {$rd read} e
$rd unsubscribe testchan
$rd read
$rd close
set stats [r cmdresult.stats]
assert {[dict get $stats rejected_count] >= 1}
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
assert_equal [dict get $entry status] "rejected"
assert_equal [dict get $entry command] "set"
assert_match {*only*(p|s)subscribe*} [string tolower [dict get $entry rejection_context]]
r cmdresult.unsubscribe
}
test {Module commandresult - rejected: not enough replicas (NOREPLICAS)} {
cleanup_callback
r cmdresult.register rejected
r config set min-replicas-to-write 100
catch {r set foo bar} e
assert_match {*NOREPLICAS*} $e
r config set min-replicas-to-write 0
set stats [r cmdresult.stats]
assert {[dict get $stats rejected_count] >= 1}
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
assert_equal [dict get $entry status] "rejected"
assert_equal [dict get $entry command] "set"
assert_match {*NOREPLICAS*} [dict get $entry rejection_context]
r cmdresult.unsubscribe
}
test {Module commandresult - rejected: out of memory (OOM)} {
cleanup_callback
r cmdresult.register rejected
r config set maxmemory 1
r config set maxmemory-policy noeviction
catch {r set oomkey oomval} e
assert_match {*OOM*} $e
r config set maxmemory 0
set stats [r cmdresult.stats]
assert {[dict get $stats rejected_count] >= 1}
set log [r cmdresult.getlog 1]
set entry [lindex $log 0]
assert_equal [dict get $entry status] "rejected"
assert_match {*OOM*} [dict get $entry rejection_context]
r cmdresult.unsubscribe
}
test {Unload the module - commandresult} {
catch {r cmdresult.unsubscribe}
assert_equal {OK} [r module unload commandresult]
}
}