diff --git a/src/Makefile b/src/Makefile index 24b9a39ba..b3ebd13b8 100644 --- a/src/Makefile +++ b/src/Makefile @@ -382,7 +382,7 @@ endif REDIS_SERVER_NAME=redis-server$(PROG_SUFFIX) REDIS_SENTINEL_NAME=redis-sentinel$(PROG_SUFFIX) -REDIS_SERVER_OBJ=threads_mngr.o memory_prefetch.o adlist.o quicklist.o ae.o anet.o dict.o ebuckets.o eventnotifier.o iothread.o mstr.o entry.o kvstore.o fwtree.o estore.o server.o sds.o zmalloc.o lzf_c.o lzf_d.o pqsort.o zipmap.o sha1.o ziplist.o release.o networking.o util.o object.o db.o replication.o rdb.o t_string.o t_list.o t_set.o t_zset.o t_hash.o config.o aof.o pubsub.o multi.o debug.o sort.o intset.o syncio.o cluster.o cluster_asm.o cluster_legacy.o cluster_slot_stats.o crc16.o endianconv.o slowlog.o eval.o bio.o rio.o rand.o memtest.o syscheck.o crcspeed.o crccombine.o crc64.o bitops.o sentinel.o notify.o setproctitle.o blocked.o hyperloglog.o latency.o sparkline.o redis-check-rdb.o redis-check-aof.o geo.o lazyfree.o module.o evict.o expire.o geohash.o geohash_helper.o childinfo.o defrag.o siphash.o rax.o t_stream.o listpack.o localtime.o lolwut.o lolwut5.o lolwut6.o lolwut8.o acl.o tracking.o socket.o tls.o sha256.o timeout.o setcpuaffinity.o monotonic.o mt19937-64.o resp_parser.o call_reply.o script_lua.o script.o functions.o function_lua.o commands.o strl.o connection.o unix.o logreqres.o keymeta.o chk.o hotkeys.o +REDIS_SERVER_OBJ=threads_mngr.o memory_prefetch.o adlist.o quicklist.o ae.o anet.o dict.o ebuckets.o eventnotifier.o iothread.o mstr.o entry.o kvstore.o fwtree.o estore.o server.o sds.o zmalloc.o lzf_c.o lzf_d.o pqsort.o zipmap.o sha1.o ziplist.o release.o networking.o util.o object.o db.o replication.o rdb.o t_string.o t_list.o t_set.o t_zset.o t_hash.o config.o aof.o pubsub.o multi.o debug.o sort.o intset.o syncio.o cluster.o cluster_asm.o cluster_legacy.o cluster_slot_stats.o crc16.o endianconv.o slowlog.o eval.o bio.o rio.o rand.o memtest.o syscheck.o crcspeed.o crccombine.o crc64.o bitops.o sentinel.o notify.o setproctitle.o blocked.o hyperloglog.o latency.o sparkline.o redis-check-rdb.o redis-check-aof.o geo.o lazyfree.o module.o evict.o expire.o geohash.o geohash_helper.o childinfo.o defrag.o siphash.o rax.o t_stream.o listpack.o localtime.o lolwut.o lolwut5.o lolwut6.o lolwut8.o acl.o tracking.o socket.o tls.o sha256.o timeout.o setcpuaffinity.o monotonic.o mt19937-64.o resp_parser.o call_reply.o script_lua.o script.o functions.o function_lua.o commands.o strl.o connection.o unix.o logreqres.o keymeta.o chk.o hotkeys.o gcra.o REDIS_CLI_NAME=redis-cli$(PROG_SUFFIX) REDIS_CLI_OBJ=anet.o adlist.o dict.o redis-cli.o zmalloc.o release.o ae.o redisassert.o crcspeed.o crccombine.o crc64.o siphash.o crc16.o monotonic.o cli_common.o mt19937-64.o strl.o cli_commands.o REDIS_BENCHMARK_NAME=redis-benchmark$(PROG_SUFFIX) diff --git a/src/commands.def b/src/commands.def index 1faf2522d..507e0e130 100644 --- a/src/commands.def +++ b/src/commands.def @@ -11039,6 +11039,34 @@ struct COMMAND_ARG DIGEST_Args[] = { {MAKE_ARG("key",ARG_TYPE_KEY,0,NULL,NULL,NULL,CMD_ARG_NONE,0,NULL)}, }; +/********** GCRA ********************/ + +#ifndef SKIP_CMD_HISTORY_TABLE +/* GCRA history */ +#define GCRA_History NULL +#endif + +#ifndef SKIP_CMD_TIPS_TABLE +/* GCRA tips */ +#define GCRA_Tips NULL +#endif + +#ifndef SKIP_CMD_KEY_SPECS_TABLE +/* GCRA key specs */ +keySpec GCRA_Keyspecs[1] = { +{NULL,CMD_KEY_RW|CMD_KEY_ACCESS|CMD_KEY_UPDATE,KSPEC_BS_INDEX,.bs.index={1},KSPEC_FK_RANGE,.fk.range={0,1,0}} +}; +#endif + +/* GCRA argument table */ +struct COMMAND_ARG GCRA_Args[] = { +{MAKE_ARG("key",ARG_TYPE_KEY,0,NULL,NULL,NULL,CMD_ARG_NONE,0,NULL)}, +{MAKE_ARG("max-burst",ARG_TYPE_INTEGER,-1,NULL,NULL,NULL,CMD_ARG_NONE,0,NULL)}, +{MAKE_ARG("requests-per-period",ARG_TYPE_INTEGER,-1,NULL,NULL,NULL,CMD_ARG_NONE,0,NULL)}, +{MAKE_ARG("period",ARG_TYPE_DOUBLE,-1,NULL,NULL,NULL,CMD_ARG_NONE,0,NULL)}, +{MAKE_ARG("count",ARG_TYPE_INTEGER,-1,"NUM_REQUESTS",NULL,NULL,CMD_ARG_OPTIONAL,0,NULL)}, +}; + /********** GET ********************/ #ifndef SKIP_CMD_HISTORY_TABLE @@ -11969,6 +11997,7 @@ struct COMMAND_STRUCT redisCommandTable[] = { {MAKE_CMD("decrby","Decrements a number from the integer value of a key. Uses 0 as initial value if the key doesn't exist.","O(1)","1.0.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,DECRBY_History,0,DECRBY_Tips,0,decrbyCommand,3,CMD_WRITE|CMD_DENYOOM|CMD_FAST,ACL_CATEGORY_STRING,DECRBY_Keyspecs,1,NULL,2),.args=DECRBY_Args}, {MAKE_CMD("delex","Conditionally removes the specified key based on value or digest comparison.","O(1) for IFEQ/IFNE, O(N) for IFDEQ/IFDNE where N is the length of the string value.","8.4.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,DELEX_History,0,DELEX_Tips,0,delexCommand,-2,CMD_WRITE|CMD_FAST,ACL_CATEGORY_STRING,DELEX_Keyspecs,1,delexGetKeys,2),.args=DELEX_Args}, {MAKE_CMD("digest","Returns the XXH3 hash of a string value.","O(N) where N is the length of the string value.","8.4.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,DIGEST_History,0,DIGEST_Tips,0,digestCommand,2,CMD_READONLY|CMD_FAST,ACL_CATEGORY_STRING,DIGEST_Keyspecs,1,NULL,1),.args=DIGEST_Args}, +{MAKE_CMD("gcra","Rate limit via GCRA (Generic Cell Rate Algorithm).","O(1)","8.8.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,GCRA_History,0,GCRA_Tips,0,gcraCommand,-5,CMD_WRITE|CMD_DENYOOM|CMD_FAST,ACL_CATEGORY_STRING,GCRA_Keyspecs,1,NULL,5),.args=GCRA_Args}, {MAKE_CMD("get","Returns the string value of a key.","O(1)","1.0.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,GET_History,0,GET_Tips,0,getCommand,2,CMD_READONLY|CMD_FAST,ACL_CATEGORY_STRING,GET_Keyspecs,1,NULL,1),.args=GET_Args}, {MAKE_CMD("getdel","Returns the string value of a key after deleting the key.","O(1)","6.2.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,GETDEL_History,0,GETDEL_Tips,0,getdelCommand,2,CMD_WRITE|CMD_FAST,ACL_CATEGORY_STRING,GETDEL_Keyspecs,1,NULL,1),.args=GETDEL_Args}, {MAKE_CMD("getex","Returns the string value of a key after setting its expiration time.","O(1)","6.2.0",CMD_DOC_NONE,NULL,NULL,"string",COMMAND_GROUP_STRING,GETEX_History,0,GETEX_Tips,0,getexCommand,-2,CMD_WRITE|CMD_FAST,ACL_CATEGORY_STRING,GETEX_Keyspecs,1,NULL,2),.args=GETEX_Args}, diff --git a/src/commands/gcra.json b/src/commands/gcra.json new file mode 100644 index 000000000..d6091fcfb --- /dev/null +++ b/src/commands/gcra.json @@ -0,0 +1,92 @@ +{ + "GCRA": { + "summary": "Rate limit via GCRA (Generic Cell Rate Algorithm).", + "complexity": "O(1)", + "group": "string", + "since": "8.8.0", + "arity": -5, + "function": "gcraCommand", + "command_flags": [ + "WRITE", + "DENYOOM", + "FAST" + ], + "acl_categories": [ + "STRING" + ], + "key_specs": [ + { + "flags": [ + "RW", + "ACCESS", + "UPDATE" + ], + "begin_search": { + "index": { + "pos": 1 + } + }, + "find_keys": { + "range": { + "lastkey": 0, + "step": 1, + "limit": 0 + } + } + } + ], + "reply_schema": { + "type": "array", + "minItems": 5, + "maxItems": 5, + "description": "Rate limiting result", + "items": [ + { + "type": "integer", + "description": "Limited: 0 if allowed, 1 if rate limited" + }, + { + "type": "integer", + "description": "Max request number: always equal to max_burst+1" + }, + { + "type": "integer", + "description": "Number of requests available immediately" + }, + { + "type": "integer", + "description": "Retry after: seconds after which caller should retry. Always -1 if not limited" + }, + { + "type": "integer", + "description": "Full burst after: seconds after which a full burst will be allowed" + } + ] + }, + "arguments": [ + { + "name": "key", + "type": "key", + "key_spec_index": 0 + }, + { + "name": "max-burst", + "type": "integer" + }, + { + "name": "requests-per-period", + "type": "integer" + }, + { + "name": "period", + "type": "double" + }, + { + "name": "count", + "type": "integer", + "token": "NUM_REQUESTS", + "optional": true + } + ] + } +} diff --git a/src/gcra.c b/src/gcra.c new file mode 100644 index 000000000..f264b2754 --- /dev/null +++ b/src/gcra.c @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026-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 "server.h" +#include + +/* GCRA algorithm for rate limiting. + * Implementation is heavily based on the implementation of (redis-cell) + * [https://github.com/brandur/redis-cell] by (brandur)[https://github.com/brandur]. + * + * It is a leaky-bucket type algorithm but instead of periodically dripping, we + * calculate the next time the bucket has capacity - called + * Theoretical arrival time(TaT) by the algorithm. We allow requests at a + * sustained rate (f.e 5 request per 10 seconds, i.e 1 request per 2 seconds) + * but also allow bursts of multiple request at one time. + * + * Explanation of the algorithm follows using the leaky-bucket analogy. + * + * GCRA works by keeping track of the next TaT and updating it after requests + * are allowed. Let T be the emission interval for a request - in the + * leaky-bucket analogy this will be the period at which the bucket drips. + * Using N requests will result in the (next TaT) = (current TaT) + N * T (time + * needed to drain the bucket). To determine if a request can be allowed we can + * calculate the time at which "the bucket dripped", which is TaT-T. + * If this time is in the past the request is allowed, otherwise we wait and TaT + * is not updated. This only accounts for 1 request though. In order to allow + * bursts we can imagine a full burst fully filling an empty bucket, thus + * we need to calculate the time after which "the bucket will completely drain" + * the requests of the burst - this will be t = T * max_burst. + * At last the allowance check will be: + * + * "now" >= TaT - (T + t) + * + * And in this case a picture is worth about 250 words: + * + * +-------------------+ + * | ALLOWED REQUEST | + * +-------------------+ + * + * +-----------+ +-----+ +-----+ + * | allow at | | now | | TaT | + * | (past) | +-----+ +-----+ + * +-----------+ | | + * | | + * ---+-----------------------+----------+-----------> time + * |//////////////////////////////////| + * |//////////////////////////////////| + * +----------------------------------+ + * | | + * |<------------- t + T ------------>| + * + * + * +------------------------------------------+ + * | T = Emission interval | + * | t = Capacity of bucket | + * | t + T = Delay variation tolerance | + * | tat = Theoretical arrival time | + * | now = Actual time of request | + * +------------------------------------------+ + * + * (ASCII art adapted from https://brandur.org/rate-limiting). */ + +/* GCRA key max_burst requests_per_period period [NUM_REQUESTS count] + * + * key: Key related to specific rate limiting case + * max_burst: Maximum requests allowed as burst (in addition to sustained rate) + * requests_per_period: Number of requests allowed per period + * period: Period in seconds for calculating sustained rate + * num_requests: Optional, cost of this request (default: 1) + */ +void gcraCommand(client *c) { + robj *key = c->argv[1]; + + /* GCRA parameters */ + long max_burst; + long requests_per_period; + long num_requests = 1; + double period; + + /* Variables used in the reply */ + int limited; /* Whether or not the request was limited */ + long long remaining = 0; /* Number of requests available immediately */ + long long retry_after_s = -1; /* Time in seconds after which the caller can retry */ + long long reset_after_s = 0; /* Number of seconds after which a full burst will be allowed */ + + if (c->argc > 7) { + addReplyErrorArity(c); + return; + } + + if (getPositiveLongFromObjectOrReply(c, c->argv[2], &max_burst, NULL) != C_OK) { + return; + } + if (likely(max_burst < LONG_MAX)) max_burst += 1; + + if (getRangeLongFromObjectOrReply(c, c->argv[3], 1, LONG_MAX, &requests_per_period, NULL) != C_OK) { + return; + } + + if (getDoubleFromObjectOrReply(c, c->argv[4], &period, NULL) != C_OK) { + return; + } + if (period <= 0 || period >= 1e12) { + addReplyError(c, "period must be > 0 and < 1e12"); + return; + } + + if (c->argc >= 6) { + if (strcasecmp("NUM_REQUESTS", c->argv[5]->ptr)) { + addReplyErrorObject(c, shared.syntaxerr); + return; + } + if (c->argc == 6) { + addReplyError(c, "Missing NUM_REQUESTS value"); + return; + } + if (getRangeLongFromObjectOrReply(c, c->argv[6], 1, LONG_MAX, &num_requests, NULL) != C_OK) { + return; + } + } + + ustime_t now = commandTimeSnapshot() * 1000; + + long long tat_us, new_tat_us; + dictEntryLink link; + kvobj *kv = lookupKeyWriteWithLink(c->db, key, &link); + if (checkType(c, kv, OBJ_STRING)) { + return; + } + if (kv != NULL) { + /* Note the value of the key may have been overwritten outside of the + * GCRA command (f.e by calling SET). We don't try to catch such errors + * as this would be possible only with a dedicated structures for GCRA, + * while using STRING gives us all the benefits of a redis key - + * replication, setting expiration, etc. */ + if (getLongLongFromObject(kv, &tat_us) != C_OK) { + addReplyError(c, "Invalid GCRA key"); + return; + } + if (tat_us <= 0) { + addReplyError(c, "Negative time is invalid value for GCRA"); + return; + } + } else { + tat_us = now; + } + + /* microsecond accuracy */ + double period_us = period * 1000000.; + + /* Emission interval is the minimum amount of time between requests. + * Note on calculations: + * Even if emission_interval_us becomes less than 1us, we assume it's min + * 1ms. The API is already in seconds granularity so it is expected the user + * won't need a submicrosecond accuracy. */ + long long emission_interval_us = (long long)(period_us / requests_per_period + 0.5); + if (unlikely(emission_interval_us == 0)) emission_interval_us = 1; + + /* overflow checks. In normal circumstances we shouldn't get these but the + * user may have wrongfully specified very large values. + * Note that all values are positive. */ + if (emission_interval_us > LLONG_MAX / num_requests) { + addReplyError(c, "GCRA limiting uses microsecond accuracy. Combination of period, requests_per_period and num_requests would cause an overflow"); + return; + } + if (emission_interval_us > LLONG_MAX / max_burst) { + addReplyError(c, "GCRA limiting uses microsecond accuracy. Combination of period, requests_per_period and max_burst would cause an overflow"); + return; + } + + /* Max bursts give us an amount of requests we can use up at one time. + * The variance will calculate the amount of time that many request need + * to "refill the bucket". */ + long long variance_us = emission_interval_us * max_burst; + + /* If a request is allowed the next TaT is after an emission_interval_us time. + * Hence for multiple requests we multiple by their number. */ + long long increment_us = emission_interval_us * num_requests; + + long long base_us = (now > tat_us) ? now : tat_us; + if (LLONG_MAX - base_us < increment_us) { + addReplyError(c, "GCRA limiting uses microsecond accuracy. Combination of period, requests_per_period and num_requests would cause an overflow"); + return; + } + new_tat_us = base_us + increment_us; + + /* Calculate the time a request is allowed. This is TaT, but because we allow + * a burst we move that time in the past. If the allow time is before the + * time we ask (i.e now) we allow the request, otherwise we limit it and + * calculate after how much time the user should retry. */ + long long allow_at = new_tat_us - variance_us; + long long diff_us = now - allow_at; + long long ttl_us; + if (diff_us < 0) { + limited = 1; + /* NOTE: if increment is more than variance, then number of requests is + * more than what is maximally allowed (i.e max_bursts + 1) so we leave + * retry_after_s to -1 in this case, as it should never be retried. */ + if (increment_us <= variance_us) { + retry_after_s = ceil((-diff_us) / 1000000.); + } + ttl_us = tat_us - now; + } else { + limited = 0; + ttl_us = new_tat_us - now; + robj *tatobj = createStringObjectFromLongLong(new_tat_us); + setKeyByLink(c, c->db, key, &tatobj, kv ? SETKEY_ALREADY_EXIST : SETKEY_DOESNT_EXIST, &link); + notifyKeyspaceEvent(NOTIFY_STRING,"set",key,c->db->id); + + long long when = new_tat_us / 1000; + kv = setExpireByLink(c, c->db, key->ptr, when, link); + notifyKeyspaceEvent(NOTIFY_GENERIC,"expire",key,c->db->id); + + /* Replicating the command directly would mess up TaT as we use + * commandTimeSnapshot. We instead rewrite the command as SET with the + * appropriate expire time. */ + robj *pexat_obj = createStringObjectFromLongLong(when); + rewriteClientCommandVector(c, 5, shared.set, key, kv, shared.pxat, pexat_obj); + decrRefCount(pexat_obj); + + server.dirty++; + } + + long long next_us = variance_us - ttl_us; + if (next_us > -emission_interval_us) { + remaining = next_us / emission_interval_us; + } + reset_after_s = ceil(ttl_us / 1000000.); + + addReplyArrayLen(c, 5); + addReply(c, limited ? shared.cone : shared.czero); + addReplyLongLong(c, max_burst); + addReplyLongLong(c, remaining); + addReplyLongLong(c, retry_after_s); + addReplyLongLong(c, reset_after_s); +} diff --git a/src/server.h b/src/server.h index e6f930cf4..a56775082 100644 --- a/src/server.h +++ b/src/server.h @@ -4437,6 +4437,7 @@ void quitCommand(client *c); void resetCommand(client *c); void failoverCommand(client *c); void digestCommand(client *c); +void gcraCommand(client *c); #if defined(__GNUC__) void *calloc(size_t count, size_t size) __attribute__ ((deprecated)); diff --git a/tests/unit/gcra.tcl b/tests/unit/gcra.tcl new file mode 100644 index 000000000..aa9ed726d --- /dev/null +++ b/tests/unit/gcra.tcl @@ -0,0 +1,266 @@ +start_server {tags {"gcra" "external:skip"}} { + test {GCRA - argument validation} { + # Wrong number of arguments (too few) + catch {r gcra} err + assert_match "*wrong number of arguments*" $err + catch {r gcra mykey} err + assert_match "*wrong number of arguments*" $err + catch {r gcra mykey 10} err + assert_match "*wrong number of arguments*" $err + catch {r gcra mykey 10 5} err + assert_match "*wrong number of arguments*" $err + + # max_burst must be non-negative integer + catch {r gcra mykey -1 5 10} err + assert_match "*out of range*" $err + catch {r gcra mykey notanumber 5 10} err + assert_match "*out of range*" $err + + # tokens_per_period must be >= 1 + catch {r gcra mykey 10 0 10} err + assert_match "*out of range*" $err + catch {r gcra mykey 10 -1 10} err + assert_match "*out of range*" $err + catch {r gcra mykey 10 notanumber 10} err + assert_match "*not an integer*" $err + + # period must be positive + catch {r gcra mykey 10 5 0} err + assert_match "*period must be > 0*" $err + catch {r gcra mykey 10 5 -1} err + assert_match "*period must be > 0*" $err + catch {r gcra mykey 10 5 notanumber} err + assert_match "*not a valid float*" $err + + # tokens (optional) must be >= 1 + catch {r gcra mykey 10 5 10 NUM_REQUESTS} err + assert_match "*Missing NUM_REQUESTS value*" $err + catch {r gcra mykey 10 5 10 NUM_REQUESTS 0} err + assert_match "*out of range*" $err + catch {r gcra mykey 10 5 10 NUM_REQUESTS -1} err + assert_match "*out of range*" $err + catch {r gcra mykey 10 5 10 NUM_REQUESTS notanumber} err + assert_match "*not an integer*" $err + + # Valid arguments with default tokens + r del mykey + set result [r gcra mykey 10 5 60] + assert_equal 5 [llength $result] + set limited [lindex $result 0] + set max_req_num [lindex $result 1] + assert_equal $limited 0 + assert_equal 11 $max_req_num + + # Valid arguments with explicit tokens + r del mykey + set result [r gcra mykey 10 5 60 NUM_REQUESTS 2] + assert_equal 5 [llength $result] + assert_equal 11 [lindex $result 1] + + # Period accepts fractional seconds + r del mykey + set result [r gcra mykey 10 5 0.5] + assert_equal 5 [llength $result] + } + + test {GCRA - first request is allowed} { + r del mykey + set result [r gcra mykey 5 10 60] + set limited [lindex $result 0] + # First request should not be limited + assert_equal 0 $limited + } + + test {GCRA - requests within burst are allowed} { + r del mykey + # max_burst=5, tokens_per_period=1, period=60 + # This allows burst of 6 requests (max_burst + 1) + for {set i 0} {$i < 6} {incr i} { + set result [r gcra mykey 5 1 60] + set limited [lindex $result 0] + assert_equal 0 $limited "Request $i should be allowed" + } + # Request 6 should be limited + set result [r gcra mykey 5 1 60] + set limited [lindex $result 0] + assert_equal 1 $limited "Request 6 should be limited" + } + + test {GCRA - retry_after is positive when limited} { + r del mykey + # Exhaust burst + for {set i 0} {$i < 3} {incr i} { + r gcra mykey 2 1 60 + } + # Next request should be limited with positive retry_after + set result [r gcra mykey 2 1 60] + set limited [lindex $result 0] + set retry_after [lindex $result 3] + assert_equal 1 $limited + assert {$retry_after > 0} + } + + test {GCRA - retry_after is negative when allowed} { + r del mykey + set result [r gcra mykey 5 1 60] + set limited [lindex $result 0] + set retry_after [lindex $result 3] + assert_equal 0 $limited + assert {$retry_after < 0} + } + + test {GCRA - num_avail_req decreases with each request} { + r del mykey + set result1 [r gcra mykey 5 1 60] + set avail1 [lindex $result1 2] + + set result2 [r gcra mykey 5 1 60] + set avail2 [lindex $result2 2] + + assert {$avail2 < $avail1} + } + + test {GCRA - multiple tokens consumed per request} { + r del mykey + # max_burst=5, so 6 tokens available initially + # Consume 1 token (default) + set result1 [r gcra mykey 5 1 60] + set avail1 [lindex $result1 2] + assert_equal 5 $avail1 + + r del mykey + # Consume 3 tokens from fresh state + set result2 [r gcra mykey 5 1 60 NUM_REQUESTS 3] + set avail2 [lindex $result2 2] + assert_equal 3 $avail2 + } + + test {GCRA - rate recovery over time} { + r del mykey + # max_burst=1, tokens_per_period=1, period=1 (1 token per second) + # Exhaust: 2 allowed (burst+1), then limited + r gcra mykey 1 1 1 + r gcra mykey 1 1 1 + set result [r gcra mykey 1 1 1] + assert_equal 1 [lindex $result 0] "Should be limited" + + # Wait for rate to recover + after 1100 + + # Should be allowed again + set result [r gcra mykey 1 1 1] + assert_equal 0 [lindex $result 0] "Should be allowed after recovery" + } + + test {GCRA - full_burst_after indicates time to full recovery} { + r del mykey + # Consume some tokens + r gcra mykey 5 1 60 + r gcra mykey 5 1 60 + + set result [r gcra mykey 5 1 60] + set full_burst_after [lindex $result 4] + + # full_burst_after should be positive (time until full burst available) + # Since we've taken from the burst twice the reset time was incremented + # by the rate twice + assert {$full_burst_after >= 179} + + r del mykey + r gcra mykey 5 1 60 + set result [r gcra mykey 5 1 60 NUM_REQUESTS 4] + set full_burst_after [lindex $result 4] + assert {$full_burst_after >= 299} + } + + test {GCRA - different keys are independent} { + r del key1 + r del key2 + + # Exhaust key1 + for {set i 0} {$i < 3} {incr i} { + r gcra key1 2 1 60 + } + set result1 [r gcra key1 2 1 60] + assert_equal 1 [lindex $result1 0] "key1 should be limited" + + # key2 should still be available + set result2 [r gcra key2 2 1 60] + assert_equal 0 [lindex $result2 0] "key2 should be allowed" + } + + test {GCRA - max_burst of 0 allows only sustained rate} { + r del mykey + # max_burst=0, tokens_per_period=1, period=1 + # Only 1 request allowed initially (0+1) + set result [r gcra mykey 0 1 1] + assert_equal 0 [lindex $result 0] "First request allowed" + assert_equal 1 [lindex $result 1] "max_req_num should be 1" + + # Second request should be limited + set result [r gcra mykey 0 1 1] + assert_equal 1 [lindex $result 0] "Second request limited" + } + + test {GCRA - wrong type error} { + r del mykey + r lpush mykey "value" + catch {r gcra mykey 5 1 60} err + assert_match "*WRONGTYPE*" $err + + r del mykey + r sadd mykey "value" + catch {r gcra mykey 5 1 60} err + assert_match "*WRONGTYPE*" $err + } + + test {GCRA - overflow} { + r del mykey + catch {r gcra mykey 1 1 86400 NUM_REQUESTS 200000000} err + assert_match "*would cause an overflow*" $err + + r del mykey + catch {r gcra mykey 200000000 1 86400} err + assert_match "*would cause an overflow*" $err + + r del mykey + catch {r gcra mykey 1 1 2147483647 NUM_REQUESTS 2147483647} err + assert_match "*would cause an overflow*" $err + } +} + +start_server {tags {"gcra repl" "external:skip"}} { + set replica [srv 0 client] + set replica_host [srv 0 host] + set replica_port [srv 0 port] + set replica_log [srv 0 stdout] + + start_server {tags {}} { + set master [srv 0 client] + set master_host [srv 0 host] + set master_port [srv 0 port] + + $master flushdb + $replica flushdb + + $replica replicaof $master_host $master_port + wait_for_condition 100 100 { + [s -1 master_link_status] eq "up" + } else { + fail "Master <-> Replica didn't finish sync" + } + + set cmdinfo [$replica info commandstats] + assert_equal [lsearch -glob $cmdinfo "cmdstat_gcra:*"] -1 + assert_equal [lsearch -glob $cmdinfo "cmdstat_set:*"] -1 + + $master del mykey + $master gcra mykey 2 1 1000 NUM_REQUESTS 2 + + wait_for_ofs_sync $master $replica + + set cmdinfo [$replica info commandstats] + assert_equal [lsearch -glob $cmdinfo "cmdstat_gcra:*"] -1 + assert_morethan_equal [lsearch -glob $cmdinfo "cmdstat_set:*"] 0 + } +}