-
Notifications
You must be signed in to change notification settings - Fork 58
feat: add a WebSocket watch session with in-stream progress requests #226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -31,6 +31,7 @@ local semaphore = require("ngx.semaphore") | |||||||||||||
| local health_check = require("resty.etcd.health_check") | ||||||||||||||
| local pl_path = require("pl.path") | ||||||||||||||
| local grpc_proto = require("resty.etcd.proto") | ||||||||||||||
| local ws_client = require("resty.websocket.client") | ||||||||||||||
| math.randomseed(now() * 1000 + ngx.worker.pid()) | ||||||||||||||
|
|
||||||||||||||
| local INIT_COUNT_RESIZE = 2e8 | ||||||||||||||
|
|
@@ -1029,6 +1030,199 @@ local function create_watch_request(key, attr) | |||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| -- WebSocket watch session. | ||||||||||||||
| -- | ||||||||||||||
| -- etcd's JSON gateway is half-duplex over plain HTTP/1.1: Go's net/http | ||||||||||||||
| -- server emits no response bytes while the request body is still open. | ||||||||||||||
| -- etcd also wraps /v3/ in grpc-websocket-proxy, so a WebSocket upgrade on | ||||||||||||||
| -- /v3/watch gives a full-duplex stream: one WatchRequest per text frame in, | ||||||||||||||
| -- one WatchResponse per text frame out. That makes in-stream | ||||||||||||||
| -- progress_request usable as a delivery barrier (apache/apisix#13777). | ||||||||||||||
|
|
||||||||||||||
| local ws_session_mt = {} | ||||||||||||||
| ws_session_mt.__index = ws_session_mt | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| local function ws_decode_frame(self, frame) | ||||||||||||||
| local body, err = decode_json(frame) | ||||||||||||||
| if not body then | ||||||||||||||
| return nil, "failed to decode json body: " .. (err or " unknown") | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| if body.error and body.error.http_code and body.error.http_code >= 500 then | ||||||||||||||
| health_check.report_failure(self.endpoint.http_host) | ||||||||||||||
| return nil, self.endpoint.http_host .. ": " | ||||||||||||||
| .. (body.error.http_status or body.error.http_code) | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| if body.result and body.result.events then | ||||||||||||||
| for _, event in ipairs(body.result.events) do | ||||||||||||||
| if event.kv.value then -- DELETE not have value | ||||||||||||||
| event.kv.value = decode_base64(event.kv.value or "") | ||||||||||||||
| event.kv.value = self.cli.serializer.deserialize(event.kv.value) | ||||||||||||||
| end | ||||||||||||||
| event.kv.key = decode_base64(event.kv.key) | ||||||||||||||
| if event.prev_kv then | ||||||||||||||
| event.prev_kv.value = decode_base64(event.prev_kv.value or "") | ||||||||||||||
| event.prev_kv.value = self.cli.serializer.deserialize(event.prev_kv.value) | ||||||||||||||
| event.prev_kv.key = decode_base64(event.prev_kv.key) | ||||||||||||||
| end | ||||||||||||||
| end | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| return body | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| -- returns one decoded WatchResponse, or nil + "timeout"/"closed"/error | ||||||||||||||
| function ws_session_mt.recv(self, timeout) | ||||||||||||||
| if self.pending then | ||||||||||||||
| local res = self.pending | ||||||||||||||
| self.pending = nil | ||||||||||||||
| return res | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| local ws = self.ws | ||||||||||||||
| ws:set_timeout((timeout or self.timeout) * 1000) | ||||||||||||||
|
|
||||||||||||||
| local buf | ||||||||||||||
| while true do | ||||||||||||||
| local data, typ, err = ws:recv_frame() | ||||||||||||||
| if not data then | ||||||||||||||
| if err and str_find(err, "timeout", 1, true) then | ||||||||||||||
| return nil, "timeout" | ||||||||||||||
| end | ||||||||||||||
| return nil, err or "closed" | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| if typ == "text" or typ == "binary" or typ == "continuation" then | ||||||||||||||
| if err == "again" then -- fragmented frame, more to come | ||||||||||||||
| buf = (buf or "") .. data | ||||||||||||||
| else | ||||||||||||||
| if buf then | ||||||||||||||
| data = buf .. data | ||||||||||||||
| end | ||||||||||||||
| return ws_decode_frame(self, data) | ||||||||||||||
| end | ||||||||||||||
| elseif typ == "ping" then | ||||||||||||||
| ws:send_pong(data) | ||||||||||||||
| elseif typ == "close" then | ||||||||||||||
| return nil, "closed" | ||||||||||||||
| end | ||||||||||||||
| -- pong or unknown frame: keep reading | ||||||||||||||
| end | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| -- asks etcd how far this stream has delivered; the reply arrives via recv() | ||||||||||||||
| -- as a WatchResponse with no events | ||||||||||||||
| function ws_session_mt.request_progress(self) | ||||||||||||||
| local bytes, err = self.ws:send_text('{"progress_request":{}}') | ||||||||||||||
| if not bytes then | ||||||||||||||
| return nil, err | ||||||||||||||
| end | ||||||||||||||
| return true | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| function ws_session_mt.close(self) | ||||||||||||||
| return self.ws:close() | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| function _M.create_ws_watch_session(self, key, opts) | ||||||||||||||
| if self.unix_socket_proxy then | ||||||||||||||
| return nil, "websocket watch does not support unix socket proxy" | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| key = utils.get_real_key(self.key_prefix, key) | ||||||||||||||
|
|
||||||||||||||
| local attr = { | ||||||||||||||
| range_end = get_range_end(key), | ||||||||||||||
| start_revision = opts and opts.start_revision, | ||||||||||||||
| progress_notify = opts and opts.progress_notify, | ||||||||||||||
| filters = opts and opts.filters, | ||||||||||||||
| prev_kv = opts and opts.prev_kv, | ||||||||||||||
| watch_id = opts and opts.watch_id, | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| local create_request = create_watch_request(key, attr) | ||||||||||||||
| create_request.key = encode_base64(key) | ||||||||||||||
| create_request.range_end = encode_base64(attr.range_end) | ||||||||||||||
|
Comment on lines
+1149
to
+1151
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Encode the key produced by
🐛 Proposed fix local create_request = create_watch_request(key, attr)
- create_request.key = encode_base64(key)
- create_request.range_end = encode_base64(attr.range_end)
+ create_request.key = encode_base64(create_request.key)
+ create_request.range_end = encode_base64(create_request.range_end or "")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| local endpoint, err = choose_endpoint(self) | ||||||||||||||
| if not endpoint then | ||||||||||||||
| return nil, err | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| local conn_opts = {} | ||||||||||||||
| if self.is_auth then | ||||||||||||||
| local _, auth_err = refresh_jwt_token(self, (opts and opts.timeout) or self.timeout) | ||||||||||||||
| if auth_err then | ||||||||||||||
| return nil, auth_err | ||||||||||||||
| end | ||||||||||||||
| -- grpc-websocket-proxy turns this subprotocol into an Authorization header | ||||||||||||||
| conn_opts.protocols = "Bearer," .. self.jwt_token | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| local scheme = "ws" | ||||||||||||||
| if endpoint.scheme == "https" then | ||||||||||||||
| scheme = "wss" | ||||||||||||||
| conn_opts.ssl_verify = self.ssl_verify | ||||||||||||||
| conn_opts.server_name = self.sni or endpoint.host | ||||||||||||||
| conn_opts.client_cert = self.ssl_cert | ||||||||||||||
| conn_opts.client_priv_key = self.ssl_key | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| local ws, new_err = ws_client:new({ | ||||||||||||||
| max_payload_len = (opts and opts.max_payload_len) or 32 * 1024 * 1024, | ||||||||||||||
| }) | ||||||||||||||
| if not ws then | ||||||||||||||
| return nil, new_err | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| local uri = scheme .. "://" .. endpoint.address .. ":" .. endpoint.port | ||||||||||||||
| .. endpoint.api_prefix .. "/watch" | ||||||||||||||
|
|
||||||||||||||
| local ok, conn_err = ws:connect(uri, conn_opts) | ||||||||||||||
| if not ok then | ||||||||||||||
| health_check.report_failure(endpoint.http_host) | ||||||||||||||
| return nil, endpoint.http_host .. ": " .. conn_err | ||||||||||||||
| end | ||||||||||||||
|
Comment on lines
+1168
to
+1191
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# 2. endpoint field names produced by the endpoint constructor / choose_endpoint.
rg -n -C6 'api_prefix|http_host\s*=|address\s*=' lib/resty/etcd/ | sed -n '1,120p'
# 3. health_check usage symmetry.
rg -n -C3 'health_check\.(report_failure|report_success)' lib/resty/etcd/Repository: api7/lua-resty-etcd Length of output: 10264 🌐 Web query:
💡 Result: In lua-resty-websocket, the client:connect method accepts an optional table of parameters to configure the connection, including support for SSL/TLS settings [1][2]. The following options are supported: ssl_verify: A boolean that specifies whether to perform SSL certificate verification during the SSL handshake when using the wss:// scheme [3]. client_cert: Specifies a client certificate chain cdata object to be used during the TLS handshake [3]. This requires the use of the ngx.ssl.parse_pem_cert function from lua-resty-core to create the object [3]. If client_cert is provided, client_priv_key must also be provided [4][3]. client_priv_key: Specifies the private key corresponding to the client_cert option [3]. This object can be created using the ngx.ssl.parse_pem_priv_key function from lua-resty-core [3]. server_name: Specifies the server name (SNI) to be used during the SSL/TLS handshake with the remote server [5]. Additional supported connection options include: protocols: Used to specify the Sec-WebSocket-Protocol header [4]. origin: Used to specify the Origin header [4]. pool and pool_size: Control connection pooling behavior [1][2]. backlog: Sets a limit on the number of waiting connect operations when the pool is full [1][3]. Regarding supported versions, the library generally requires OpenResty with an Nginx version that supports the necessary cosocket and SSL features (specifically ngx_lua 0.9.11+ is required for SSL/wss support) [4]. Version-specific features such as pool and backlog management were introduced in v0.10.14 [1][5]. Citations:
🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- dependency declarations ---'
rg -n -C4 'lua-resty-websocket|resty.websocket|websocket' \
.github Makefile README.md *.rockspec *.rock 2>/dev/null || true
printf '%s\n' '--- health-check implementation and success API ---'
fd -t f . lib | sort | grep -E 'health|etcd|resty'
rg -n -C8 'function .*report_(success|failure)|report_success|report_failure' lib
printf '%s\n' '--- endpoint selection and websocket session context ---'
rg -n -C12 'choose_endpoint|create_ws_watch_session|ws_client:new|ws:connect' lib/resty/etcd/v3.luaRepository: api7/lua-resty-etcd Length of output: 16960 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- tracked dependency and packaging files ---'
git ls-files | grep -Ei '(^|/)(rockspec|rock|manifest|makefile|cmake|docker|deps|requirements|.*lock)|websocket' | sed -n '1,160p'
printf '%s\n' '--- all websocket dependency references ---'
rg -n -i -C3 'lua-resty-websocket|resty\.websocket|websocket' . --glob '!*.md' --glob '!t/data/**' | sed -n '1,220p'
printf '%s\n' '--- health-check module ---'
cat -n lib/resty/etcd/health_check.lua | sed -n '1,190p'
printf '%s\n' '--- websocket watch call path after connect ---'
cat -n lib/resty/etcd/v3.lua | sed -n '1128,1235p'Repository: api7/lua-resty-etcd Length of output: 15723 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- websocket dependency declarations in rockspecs ---'
for f in $(git ls-files 'rockspec/*.rockspec'); do
if grep -qi 'websocket' "$f"; then
printf '\n[%s]\n' "$f"
grep -in -C3 'websocket' "$f"
fi
done
printf '%s\n' '--- current build and test dependency metadata ---'
cat -n Makefile | sed -n '1,180p'
cat -n rockspec/lua-resty-etcd-master-0.1-0.rockspec | sed -n '1,180p'
printf '%s\n' '--- repository revision and changed-file context ---'
git status --short
git branch --show-current
git rev-parse --short HEADRepository: api7/lua-resty-etcd Length of output: 2781 🌐 Web query:
💡 Result: In the context of the openresty/lua-resty-websocket library, max_payload_len and client_priv_key are configuration options used when initializing WebSocket connections [1][2]. max_payload_len This parameter defines the maximum length of the payload allowed for both sending and receiving WebSocket frames [2][3]. It defaults to 65535 [2][4]. When a frame exceeds this limit, the library will reject it [5]. Related configuration options include max_recv_len (defaults to max_payload_len) and max_send_len (defaults to max_payload_len) [1][2][4]. client_priv_key This parameter specifies the private key used during the TLS handshake for secure (WSS) WebSocket connections [2][3]. It must be provided alongside the client_cert option [1][2]. The private key object is typically created using the ngx.ssl.parse_pem_priv_key function from the lua-resty-core library [2][3]. If client_cert is provided, the library asserts that client_priv_key is also present [1]. It is subsequently used in the socket's TLS configuration (e.g., via sock:setclientcert) during the handshake process [1][6]. Citations:
🌐 Web query:
💡 Result: The lua-resty-websocket library does not natively support specific configuration parameters named client_priv_key or server_name in its connection methods [1]. Based on the available documentation, there is no evidence that these parameters exist within the library's API. Generally, SSL/TLS configurations for OpenResty libraries are handled by the underlying lua-resty-core or Nginx stream/http SSL directives rather than the websocket library itself. Citations: 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- upstream websocket client option implementation ---'
curl -fsSL https://raw.githubusercontent.com/openresty/lua-resty-websocket/master/lib/resty/websocket/client.lua \
| nl -ba | grep -n -C5 -E 'max_payload_len|ssl_verify|server_name|client_cert|client_priv_key|function _M:new|function _M:connect' \
| sed -n '1,240p'
printf '%s\n' '--- upstream websocket release tags ---'
curl -fsSL 'https://api.github.com/repos/openresty/lua-resty-websocket/tags?per_page=100' \
| jq -r '.[] | [.name, .commit.sha] | `@tsv`' | sed -n '1,120p'Repository: api7/lua-resty-etcd Length of output: 1271 🏁 Script executed: #!/bin/bash
set -eu
base='https://raw.githubusercontent.com/openresty/lua-resty-websocket'
for tag in v0.01 v0.02 v0.03 v0.04 v0.05 v0.06 v0.07 v0.08 v0.09 v0.10 v0.11 v0.12 v0.13 v0.14 master; do
url="$base/$tag/lib/resty/websocket/client.lua"
body=$(curl -fsSL "$url" 2>/dev/null || true)
printf '\n[%s]\n' "$tag"
printf '%s\n' "$body" | grep -n -E 'max_payload_len|ssl_verify|server_name|client_cert|client_priv_key|function .*connect|function .*new' || true
done
printf '\n--- README option references ---\n'
curl -fsSL "$base/master/README.markdown" \
| grep -n -C4 -E 'max_payload_len|ssl_verify|server_name|client_cert|client_priv_key' \
| sed -n '1,220p'Repository: api7/lua-resty-etcd Length of output: 16777 Declare and pin 🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| local req_body, encode_err = encode_json({create_request = create_request}) | ||||||||||||||
| if not req_body then | ||||||||||||||
| ws:close() | ||||||||||||||
| return nil, encode_err | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| local bytes, send_err = ws:send_text(req_body) | ||||||||||||||
| if not bytes then | ||||||||||||||
| ws:close() | ||||||||||||||
| return nil, send_err | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| local session = setmetatable({ | ||||||||||||||
| cli = self, | ||||||||||||||
| ws = ws, | ||||||||||||||
| endpoint = endpoint, | ||||||||||||||
| timeout = (opts and opts.timeout) or self.timeout, | ||||||||||||||
| }, ws_session_mt) | ||||||||||||||
|
|
||||||||||||||
| -- resty.websocket.client accepts any HTTP/1.1 status line as a handshake, | ||||||||||||||
| -- so an intermediary that stripped the Upgrade header still "connects". | ||||||||||||||
| -- Only a real WatchResponse proves the stream works; keep it for recv(). | ||||||||||||||
| local first, ferr = session:recv() | ||||||||||||||
| if not first then | ||||||||||||||
| ws:close() | ||||||||||||||
| return nil, "websocket watch stream unusable: " .. (ferr or "unknown") | ||||||||||||||
| end | ||||||||||||||
| session.pending = first | ||||||||||||||
|
|
||||||||||||||
| return session | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| local get_grpc_metadata | ||||||||||||||
| do | ||||||||||||||
| local metadata = { | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: api7/lua-resty-etcd
Length of output: 41762
🏁 Script executed:
Repository: api7/lua-resty-etcd
Length of output: 35786
Declare
lua-resty-websocketor lazy-load the client.resty.etcdloadsresty.etcd.v3, which requiresresty.websocket.clientat module load time. Without the dependency, existing API calls fail before they run. Addlua-resty-websocketto the rockspec, or move the require intocreate_ws_watch_session.🤖 Prompt for AI Agents