This is an automated email from the ASF dual-hosted git repository.

shreemaanabhishek pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new b7ec0c89d fix(limit-conn): implement configurable redis key expiry 
(#12872)
b7ec0c89d is described below

commit b7ec0c89d65e6fd81e547455c6e7bb9d8bac38e8
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Tue Jan 27 17:14:10 2026 +0545

    fix(limit-conn): implement configurable redis key expiry (#12872)
---
 apisix/plugins/limit-conn.lua                      |   5 +-
 .../limit-conn/limit-conn-redis-cluster.lua        |  13 +-
 apisix/plugins/limit-conn/limit-conn-redis.lua     |  13 +-
 apisix/plugins/limit-conn/util.lua                 | 102 +++++++++++---
 apisix/utils/redis-schema.lua                      |  14 +-
 docs/en/latest/plugins/limit-conn.md               |   1 +
 docs/zh/latest/plugins/limit-conn.md               |   1 +
 t/cli/test_limit_conn_redis_ttl.sh                 | 148 +++++++++++++++++++++
 8 files changed, 272 insertions(+), 25 deletions(-)

diff --git a/apisix/plugins/limit-conn.lua b/apisix/plugins/limit-conn.lua
index ddb104602..6fdefd1f4 100644
--- a/apisix/plugins/limit-conn.lua
+++ b/apisix/plugins/limit-conn.lua
@@ -17,7 +17,6 @@
 local core                              = require("apisix.core")
 local limit_conn                        = 
require("apisix.plugins.limit-conn.init")
 local redis_schema                      = require("apisix.utils.redis-schema")
-local policy_to_additional_properties   = redis_schema.schema
 local plugin_name                       = "limit-conn"
 local workflow                           = require("apisix.plugins.workflow")
 
@@ -55,7 +54,7 @@ local schema = {
             },
         },
     },
-    ["then"] = policy_to_additional_properties.redis,
+    ["then"] = redis_schema.limit_conn_redis_schema,
     ["else"] = {
         ["if"] = {
             properties = {
@@ -64,7 +63,7 @@ local schema = {
                 },
             },
         },
-        ["then"] = policy_to_additional_properties["redis-cluster"],
+        ["then"] = redis_schema.limit_conn_redis_cluster_schema,
     }
 }
 
diff --git a/apisix/plugins/limit-conn/limit-conn-redis-cluster.lua 
b/apisix/plugins/limit-conn/limit-conn-redis-cluster.lua
index 9e46a04b2..9f16ba11f 100644
--- a/apisix/plugins/limit-conn/limit-conn-redis-cluster.lua
+++ b/apisix/plugins/limit-conn/limit-conn-redis-cluster.lua
@@ -19,6 +19,7 @@ local core              = require("apisix.core")
 local util              = require("apisix.plugins.limit-conn.util")
 local setmetatable      = setmetatable
 local ngx_timer_at      = ngx.timer.at
+local ngx               = ngx
 
 local _M = {version = 0.1}
 
@@ -41,6 +42,7 @@ function _M.new(plugin_name, conf, max, burst, 
default_conn_delay)
         max = max + 0,    -- just to ensure the param is good
         unit_delay = default_conn_delay,
         red_cli = red_cli,
+        use_evalsha = false,
     }
     return setmetatable(self, mt)
 end
@@ -56,14 +58,19 @@ function _M.is_committed(self)
 end
 
 
-local function leaving_thread(premature, self, key, req_latency)
-    return util.leaving(self, self.red_cli, key, req_latency)
+local function leaving_thread(premature, self, key, req_latency, req_id)
+    return util.leaving(self, self.red_cli, key, req_latency, req_id)
 end
 
 
 function _M.leaving(self, key, req_latency)
+    local req_id
+    if ngx.ctx.limit_conn_req_ids then
+        req_id = ngx.ctx.limit_conn_req_ids[key]
+    end
+
     -- log_by_lua can't use cosocket
-    local ok, err = ngx_timer_at(0, leaving_thread, self, key, req_latency)
+    local ok, err = ngx_timer_at(0, leaving_thread, self, key, req_latency, 
req_id)
     if not ok then
         core.log.error("failed to create timer: ", err)
         return nil, err
diff --git a/apisix/plugins/limit-conn/limit-conn-redis.lua 
b/apisix/plugins/limit-conn/limit-conn-redis.lua
index 4de7a27fd..6e3251b52 100644
--- a/apisix/plugins/limit-conn/limit-conn-redis.lua
+++ b/apisix/plugins/limit-conn/limit-conn-redis.lua
@@ -18,6 +18,7 @@ local redis         = require("apisix.utils.redis")
 local core          = require("apisix.core")
 local util          = require("apisix.plugins.limit-conn.util")
 local ngx_timer_at  = ngx.timer.at
+local ngx           = ngx
 
 local setmetatable  = setmetatable
 
@@ -37,6 +38,7 @@ function _M.new(plugin_name, conf, max, burst, 
default_conn_delay)
         burst = burst,
         max = max + 0,    -- just to ensure the param is good
         unit_delay = default_conn_delay,
+        use_evalsha = true,
     }
     return setmetatable(self, mt)
 end
@@ -57,20 +59,25 @@ function _M.is_committed(self)
 end
 
 
-local function leaving_thread(premature, self, key, req_latency)
+local function leaving_thread(premature, self, key, req_latency, req_id)
 
     local conf = self.conf
     local red, err = redis.new(conf)
     if not red then
         return red, err
     end
-    return util.leaving(self, red, key, req_latency)
+    return util.leaving(self, red, key, req_latency, req_id)
 end
 
 
 function _M.leaving(self, key, req_latency)
+    local req_id
+    if ngx.ctx.limit_conn_req_ids then
+        req_id = ngx.ctx.limit_conn_req_ids[key]
+    end
+
     -- log_by_lua can't use cosocket
-    local ok, err = ngx_timer_at(0, leaving_thread, self, key, req_latency)
+    local ok, err = ngx_timer_at(0, leaving_thread, self, key, req_latency, 
req_id)
     if not ok then
         core.log.error("failed to create timer: ", err)
         return nil, err
diff --git a/apisix/plugins/limit-conn/util.lua 
b/apisix/plugins/limit-conn/util.lua
index f3ba5bd76..7e6d7fe47 100644
--- a/apisix/plugins/limit-conn/util.lua
+++ b/apisix/plugins/limit-conn/util.lua
@@ -18,36 +18,100 @@
 local assert            = assert
 local math              = require "math"
 local floor             = math.floor
+local ngx               = ngx
+local ngx_time          = ngx.time
+local uuid              = require("resty.jit-uuid")
+local core              = require("apisix.core")
+
 local _M = {version = 0.3}
+local redis_incoming_script = core.string.compress_script([=[
+    local key = KEYS[1]
+    local limit = tonumber(ARGV[1])
+    local ttl = tonumber(ARGV[2])
+    local now = tonumber(ARGV[3])
+    local req_id = ARGV[4]
+
+    redis.call('ZREMRANGEBYSCORE', key, 0, now)
+
+    local count = redis.call('ZCARD', key)
+    if count >= limit then
+        return {0, count}
+    end
+
+    redis.call('ZADD', key, now + ttl, req_id)
+    redis.call('EXPIRE', key, ttl)
+    return {1, count + 1}
+]=])
+local redis_incoming_script_sha
+
+
+local function generate_redis_sha1(red)
+    local sha1, err = red:script("LOAD", redis_incoming_script)
+    if not sha1 then
+        return nil, err
+    end
+    return sha1
+end
 
 
 function _M.incoming(self, red, key, commit)
     local max = self.max
     self.committed = false
+    local raw_key = key
     key = "limit_conn" .. ":" .. key
 
-    local conn, err
+    local conn
     if commit then
-        conn, err = red:incrby(key, 1)
-        if not conn then
-            return nil, err
+        local req_id = ngx.ctx.request_id or uuid.generate_v4()
+        if not ngx.ctx.limit_conn_req_ids then
+            ngx.ctx.limit_conn_req_ids = {}
         end
+        ngx.ctx.limit_conn_req_ids[raw_key] = req_id
+
+        local now = ngx_time()
+        local res, err
+
+        if self.use_evalsha then
+            if not redis_incoming_script_sha then
+                redis_incoming_script_sha, err = generate_redis_sha1(red)
+                if not redis_incoming_script_sha then
+                    core.log.error("failed to generate redis sha1: ", err)
+                    return nil, err
+                end
+            end
 
-        if conn > max + self.burst then
-            conn, err = red:incrby(key, -1)
-            if not conn then
-                return nil, err
+            res, err = red:evalsha(redis_incoming_script_sha, 1, key,
+                                   max + self.burst, self.conf.key_ttl, now, 
req_id)
+
+            if err and core.string.has_prefix(err, "NOSCRIPT") then
+                core.log.warn("redis evalsha failed: ", err, ". Falling back 
to eval...")
+                redis_incoming_script_sha = nil
+                res, err = red:eval(redis_incoming_script, 1, key,
+                                    max + self.burst, self.conf.key_ttl, now, 
req_id)
             end
+        else
+            res, err = red:eval(redis_incoming_script, 1, key,
+                                max + self.burst, self.conf.key_ttl, now, 
req_id)
+        end
+
+        if not res then
+            return nil, err
+        end
+
+        local allowed = res[1]
+        conn = res[2]
+
+        if allowed == 0 then
             return nil, "rejected"
         end
+
         self.committed = true
 
     else
-        local conn_from_red, err = red:get(key)
-        if err then
-            return nil, err
-        end
-        conn = (conn_from_red or 0) + 1
+        red:zremrangebyscore(key, 0, ngx_time())
+        local count, err = red:zcard(key)
+        if err then return nil, err end
+        conn = (count or 0) + 1
     end
 
     if conn > max then
@@ -60,11 +124,19 @@ function _M.incoming(self, red, key, commit)
 end
 
 
-function _M.leaving(self, red, key, req_latency)
+function _M.leaving(self, red, key, req_latency, req_id)
     assert(key)
     key = "limit_conn" .. ":" .. key
 
-    local conn, err = red:incrby(key, -1)
+    local conn, err
+    if req_id then
+        local res, err = red:zrem(key, req_id)
+        if not res then
+            return nil, err
+        end
+    end
+    conn, err = red:zcard(key)
+
     if not conn then
         return nil, err
     end
diff --git a/apisix/utils/redis-schema.lua b/apisix/utils/redis-schema.lua
index c9fdec41d..935077150 100644
--- a/apisix/utils/redis-schema.lua
+++ b/apisix/utils/redis-schema.lua
@@ -74,8 +74,20 @@ local policy_to_additional_properties = {
     },
 }
 
+local limit_conn_redis_cluster_schema = 
policy_to_additional_properties["redis-cluster"]
+limit_conn_redis_cluster_schema.properties.key_ttl = {
+    type = "integer", default = 3600,
+}
+
+local limit_conn_redis_schema = policy_to_additional_properties["redis"]
+limit_conn_redis_schema.properties.key_ttl = {
+    type = "integer", default = 3600,
+}
+
 local _M = {
-    schema = policy_to_additional_properties
+    schema = policy_to_additional_properties,
+    limit_conn_redis_cluster_schema = limit_conn_redis_cluster_schema,
+    limit_conn_redis_schema = limit_conn_redis_schema,
 }
 
 return _M
diff --git a/docs/en/latest/plugins/limit-conn.md 
b/docs/en/latest/plugins/limit-conn.md
index 8b08ab13a..c8d4c2e82 100644
--- a/docs/en/latest/plugins/limit-conn.md
+++ b/docs/en/latest/plugins/limit-conn.md
@@ -44,6 +44,7 @@ The `limit-conn` Plugin limits the rate of requests by the 
number of concurrent
 | only_use_default_delay   | boolean | False    | false       |      | If 
false, delay requests proportionally based on how much they exceed the `conn` 
limit. The delay grows larger as congestion increases. For instance, with 
`conn` being `5`, `burst` being `3`, and `default_conn_delay` being `1`, 6 
concurrent requests would result in a 1-second delay, 7 requests a 2-second 
delay, 8 requests a 3-second delay, and so on, until the total limit of `conn + 
burst` is reached, beyond which req [...]
 | key_type        | string  | False      | var   | ["var","var_combination"] | 
The type of key. If the `key_type` is `var`, the `key` is interpreted a 
variable. If the `key_type` is `var_combination`, the `key` is interpreted as a 
combination of variables.    |
 | key       | string  | False      | remote_addr |   | The key to count 
requests by. If the `key_type` is `var`, the `key` is interpreted a variable. 
The variable does not need to be prefixed by a dollar sign (`$`). If the 
`key_type` is `var_combination`, the `key` is interpreted as a combination of 
variables. All variables should be prefixed by dollar signs (`$`). For example, 
to configure the `key` to use a combination of two request headers `custom-a` 
and `custom-b`, the `key` should  [...]
+| key_ttl   | integer | False      | 3600          |   | The TTL of the Redis 
key in seconds. Used when `policy` is `redis` or `redis-cluster`. |
 | rejected_code   | integer | False      | 503   | [200,...,599]   | The HTTP 
status code returned when a request is rejected for exceeding the threshold.    
 |
 | rejected_msg    | string  | False        |       | non-empty   | The 
response body returned when a request is rejected for exceeding the threshold.  
   |
 | allow_degradation       | boolean | False      | false   |   | If true, 
allow APISIX to continue handling requests without the Plugin when the Plugin 
or its dependencies become unavailable.        |
diff --git a/docs/zh/latest/plugins/limit-conn.md 
b/docs/zh/latest/plugins/limit-conn.md
index e90fabd64..f7f8154f1 100644
--- a/docs/zh/latest/plugins/limit-conn.md
+++ b/docs/zh/latest/plugins/limit-conn.md
@@ -44,6 +44,7 @@ description: limit-conn 插件通过管理并发连接来限制请求速率。
 | only_use_default_delay | boolean | 否 | false | | 如果为 
false,则根据请求超出`conn`限制的程度按比例延迟请求。拥塞越严重,延迟就越大。例如,当 `conn` 为 `5`、`burst` 为 `3` 且 
`default_conn_delay` 为 `1` 时,6 个并发请求将导致 1 秒的延迟,7 个请求将导致 2 秒的延迟,8 个请求将导致 3 
秒的延迟,依此类推,直到达到 `conn + burst` 的总限制,超过此限制的请求将被拒绝。如果为 true,则使用 
`default_conn_delay` 延迟 `burst` 范围内的所有超额请求。超出 `conn + burst` 的请求将被立即拒绝。例如,当 
`conn` 为 `5`、`burst` 为 `3` 且 `default_conn_delay` 为 `1` 时,6、7 或 8 个并发请求都将延迟 1 
秒。|
 | key_type | string | 否 | var | ["var","var_combination"] | key 
的类型。如果`key_type` 为 `var`,则 `key` 将被解释为变量。如果 `key_type` 为 `var_combination`,则 
`key` 将被解释为变量的组合。 |
 | key | string | 否 | remote_addr | | 用于计数请求的 key。如果 `key_type` 为 `var`,则 `key` 
将被解释为变量。变量不需要以美元符号(`$`)为前缀。如果 `key_type` 为 `var_combination`,则 `key` 
会被解释为变量的组合。所有变量都应该以美元符号 (`$`) 为前缀。例如,要配置 `key` 使用两个请求头 `custom-a` 和 `custom-b` 
的组合,则 `key` 应该配置为 `$http_custom_a $http_custom_b`。|
+| key_ttl | integer | 否 | 3600 | | Redis 键的 TTL(以秒为单位)。当 `policy` 为 `redis` 或 
`redis-cluster` 时使用。 |
 | rejection_code | integer | 否 | 503 | [200,...,599] | 请求因超出阈值而被拒绝时返回的 HTTP 
状态代码。|
 | rejection_msg | string | 否 | | 非空 | 请求因超出阈值而被拒绝时返回的响应主体。|
 | allow_degradation | boolean | 否 | false | | 如果为 true,则允许 APISIX 
在插件或其依赖项不可用时继续处理没有插件的请求。|
diff --git a/t/cli/test_limit_conn_redis_ttl.sh 
b/t/cli/test_limit_conn_redis_ttl.sh
new file mode 100755
index 000000000..82fee2057
--- /dev/null
+++ b/t/cli/test_limit_conn_redis_ttl.sh
@@ -0,0 +1,148 @@
+#!/usr/bin/env bash
+
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+. ./t/cli/common.sh
+
+# Enable limit-conn plugin
+
+rm logs/worker_events.sock || true
+
+echo '
+nginx_config:
+  worker_processes: 1
+  error_log_level: info
+deployment:
+  admin:
+    admin_key:
+      - name: "admin"
+        key: edd1c9f034335f136f87ad84b625c8f1
+        role: admin
+
+apisix:
+  enable_admin: true
+  control:
+    port: 9110
+plugins:
+  - limit-conn
+' > conf/config.yaml
+
+make init
+make run
+
+admin_key="edd1c9f034335f136f87ad84b625c8f1"
+
+# Create a route with limit-conn and redis policy
+# key_ttl is set to 2 seconds
+curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
+    -H "X-API-KEY: $admin_key" \
+    -d '{
+    "methods": ["GET"],
+    "uri": "/hello",
+    "plugins": {
+        "limit-conn": {
+            "conn": 1,
+            "burst": 0,
+            "default_conn_delay": 0.1,
+            "key": "remote_addr",
+            "policy": "redis",
+            "redis_host": "127.0.0.1",
+            "redis_timeout": 1000,
+            "key_ttl": 2
+        }
+    },
+    "upstream": {
+        "type": "roundrobin",
+        "nodes": {
+            "127.0.0.1:1995": 1
+        },
+        "timeout": {
+            "connect": 300,
+            "send": 300,
+            "read": 300
+        }
+    }
+}'
+
+if [ $? -ne 0 ]; then
+    echo "failed: verify route creation"
+    exit 1
+fi
+
+sleep 0.5
+
+# Start a mock upstream server (perl) that hangs.
+# This ensures the connection stays open and limit-conn count remains 1.
+perl -e 'use IO::Socket::INET; my $s = IO::Socket::INET->new(LocalPort => 
1995, Listen => 1, Reuse => 1) or die; my $c = $s->accept(); sleep 10;' &
+PERL_PID=$!
+sleep 1
+
+# Start the request in background.
+# This request consumes the 1 allowed connection.
+curl -v http://127.0.0.1:9080/hello > /dev/null 2>&1 &
+CURL_PID=$!
+
+sleep 1
+
+# Kill APISIX hard (-9) to prevent limit-conn from decrementing the count.
+# This simulates a crash where the Redis key is left with value 1.
+if [ -f logs/nginx.pid ]; then
+    pid=$(cat logs/nginx.pid)
+    workers=$(pgrep -P $pid)
+    kill -9 $pid || true
+    echo "Killed APISIX master $pid"
+    if [ -n "$workers" ]; then
+        kill -9 $workers 2>/dev/null || true
+    fi
+fi
+
+# Clean up the background tasks
+kill $PERL_PID || echo "failed to kill upstream process"
+kill $CURL_PID || echo "failed to kill curl process"
+
+# Wait for key_ttl (2s) to expire in Redis.
+# If key_ttl works, the key should expire and vanish.
+echo "Waiting for key_ttl expiration..."
+sleep 3
+
+# Start APISIX again
+rm logs/worker_events.sock || true
+make run
+sleep 1
+
+# Start upstream again for the verification request
+perl -e 'use IO::Socket::INET; my $s = IO::Socket::INET->new(LocalPort => 
1995, Listen => 1, Reuse => 1) or die; my $c = $s->accept(); print $c "HTTP/1.1 
200 OK\r\nContent-Length: 0\r\n\r\n";' &
+NC_2_PID=$!
+sleep 1
+
+# check connection
+# If the key expired, this request should be allowed (we might get timeout or 
empty reply from nc, but NOT 503).
+# If the key persisted (ttl features broken), connection count would still be 
1, so this new request would result in 1+1 > 1 -> 503.
+status_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 
http://127.0.0.1:9080/hello)
+
+echo "Status code: $status_code"
+
+# Cleanup
+kill $NC_2_PID || true
+
+if [ "$status_code" == "503" ]; then
+    echo "failed: request blocked (503), limit-conn key did not expire"
+    exit 1
+fi
+
+echo "pass: request not blocked, key_ttl works"

Reply via email to