fix(redis): time out unresponsive Redis connections by HarshMN2345 · Pull Request #14540 · appwrite/appwrite · GitHub
Skip to content

fix(redis): time out unresponsive Redis connections - #14540

Open
HarshMN2345 wants to merge 2 commits into
mainfrom
fix-redis-read-timeout
Open

HarshMN2345 wants to merge 2 commits into
mainfrom
fix-redis-read-timeout

Conversation

@HarshMN2345

Copy link
Copy Markdown
Member

Pooled Redis clients were opened with no connect timeout and OPT_READ_TIMEOUT = -1, so a Redis that stopped answering held every request until Swoole's max_concurrency answered 503 to everything. Cache, lock, abuse, pub/sub and publisher connections (and the MQTT rate-limit client) now connect and read within _APP_REDIS_TIMEOUT (default 3 s, above the 2 s Broker::retry() blocks on BRPOP). The read timeout is set with setOption before AUTH, since a socket reused from phpredis' persistent pool ignores the pconnect argument. Pub/sub subscribers switch back to waiting indefinitely. The worker consumer is unchanged.

phpredis 6.3 leaves the socket open when an EVAL/EVALSHA/PING reply times out, so the next command reads the late reply. The cache adapter (lease saves, ping) and the lock wrapper (release) swallow those errors and returned the client to the pool. The cache adapter and Distributed now close such a socket.

Verified against a throwaway redis:7.4.7 with a password and CLIENT PAUSE ... ALL, driving the real pools from app/init/registers.php in the dev image:

  • on main, every cache/lock/abuse/publisher/publish call hung for the whole 15 s pause
  • on this branch they fail after 3-6 s (cache about 17 s through its two retries) and recover as soon as Redis answers. The subscriber survives 37 s of silence and retry()'s 2 s BRPOP still succeeds.
  • with only the timeout change, pooled cache and lock clients returned the previous key's value on every later read after one timed-out script. With the package changes they stay in sync.
  • pint --test and PHPStan on the touched files, bin/monorepo check cache|lock|queue, and the unit suites of those three packages

Closes #14490

…es open

phpredis keeps the socket open when the reply to EVAL, EVALSHA or PING
times out, so the next command on that client reads the late reply as
its own. The cache adapter swallows lease and ping failures and
Appwrite's lock wrapper swallows release failures, which put such a
client back in the pool answering every later command with the previous
command's reply.
Pooled Redis clients waited forever for a reply, so a Redis that stopped
answering held every request until Swoole hit max_concurrency and
answered 503 to everything. Connections now connect and read within
_APP_REDIS_TIMEOUT (3 s by default, above the 2 s a queue retry blocks
on a pop), set before AUTH so a reconnect cannot hang on it either.
Pub/sub subscribers keep waiting indefinitely for the next message.
@tenki-reviewer

tenki-reviewer Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

Security rules

No new WARNING or ERROR findings from security rules.

19 existing findings tracked in .semgrep/baseline.json
  • php.appwrite.guest-write-without-abuse-limit (12)
  • php.appwrite.secret-compare-timing (5)
  • php.appwrite.weak-secret-env-default (2)

Posted by Checks / Rules. Re-runs update this comment in place. Rule details and baseline: .semgrep/README.md.

@hansi-codes

hansi-codes Bot commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

🔵 Tier A · Mergeable after minor fixes

Limited by an open minor finding: Do not replay ambiguous timed-out purge scripts

Adds a configurable connect and read timeout to Redis clients used by the application pools, queue publishers, and MQTT rate limiting, while keeping pub/sub subscribers waiting indefinitely. Cache and lock adapters also close timed-out sockets to prevent late replies from desynchronizing later commands.

Verdict New comments Fixed Still open
💬 Commented 1 0 0

Note

Not approving while a bug finding is open: Do not replay ambiguous timed-out purge scripts

Finding Where
🟡 Do not replay ambiguous timed-out purge scripts packages/cache/src/Adapter/Redis.php:411
Fix with agent prompt
### Issue 1
packages/cache/src/Adapter/Redis.php:411
**Do not replay ambiguous timed-out purge scripts**

This makes `execute()` retry a script after a protocol error even though Redis may already have executed it and only its reply timed out. In particular, `LUA_PURGE_FIELD` and `LUA_PURGE_BUMP` increment the generation on every run; replaying a successful purge can return `false` and, if another cache write lands between attempts, leave that write behind despite invalidating its generation.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
📂 Walkthrough · 6
File Change
app/init/registers.php Configures the Redis timeout for pooled clients and queue publishers.
app/mqtt.php Applies the Redis timeout to MQTT rate-limit connections.
packages/cache/src/Adapter/Redis.php Closes failed sockets and treats protocol errors as retryable connection errors.
packages/lock/src/Distributed.php Closes the Redis socket when a lock script throws.
packages/queue/src/Connection/Redis.php Sets the read timeout before Redis authentication.
src/Appwrite/PubSub/Adapter/Redis.php Restores an indefinite read timeout before subscribing.
🔇 Filtered out · 1

Findings Hansi considered but did not post.

Finding Why
Cache retries can still exhaust request concurrency Verifier: The change bounds previously unbounded Redis I/O; execute() also has a finite 1 + maxRetries attempt limit, and pool acquisition has its own fixed deadline. The 1,000-request cap is real, but the 250 requests/second workload and 17-second per-request timing are not established by repository code or tests. The 4,250-request extrapolation also treats every request as executing retries, ignoring the finite connection pools and pool-acquisition failures. This is a workload-dependent overload concern, not evidence that the new timeout fails to bound unresponsive connections or violates a defined request-latency requirement.

Reviewed 9427ceb · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · See the inline comments. Summary

'went away',
'socket',
'read error on connection',
'protocol error', // how phpredis reports a timed-out script reply

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not replay ambiguous timed-out purge scripts

This makes execute() retry a script after a protocol error even though Redis may already have executed it and only its reply timed out. In particular, LUA_PURGE_FIELD and LUA_PURGE_BUMP increment the generation on every run; replaying a successful purge can return false and, if another cache write lands between attempts, leave that write behind despite invalidating its generation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/cache/src/Adapter/Redis.php
Line: 411

Comment:
**Do not replay ambiguous timed-out purge scripts**

This makes `execute()` retry a script after a protocol error even though Redis may already have executed it and only its reply timed out. In particular, `LUA_PURGE_FIELD` and `LUA_PURGE_BUMP` increment the generation on every run; replaying a successful purge can return `false` and, if another cache write lands between attempts, leave that write behind despite invalidating its generation.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟡 Minor · bug · Reply if this doesn't apply.

@github-actions

Copy link
Copy Markdown

✨ Benchmark results

Comparing main (before) → fix-redis-read-timeout (after).

Metric Before After Change
🚀 Requests/sec 178.04 196.08 🟢 +10.1%
⏱️ Latency P50 97.46 ms 89.34 ms 🟢 -8.3%
⏱️ Latency P95 230.64 ms 203.81 ms 🟢 -11.6%
Per-scenario breakdown & investigation details

Metrics below reflect the current branch (after). Δ P95 compares against the base.

Scenario P50 (ms) P95 (ms) Requests RPS Δ P95 (ms)
API total 89.34 203.81 12,540 196.08 -26.83
Account 162.06 329.21 660 10.97 -17.62
TablesDB 86.66 157.9 6,820 109.45 -30.55
Storage 82.7 183.72 3,300 54.31 -11.29
Functions 119.37 239.8 1,760 29.75 -23.8

Top API waits (after)

API request Max wait (ms)
account.name.update 523.94
tablesdb.rows.create 421.46
account.prefs.update 416.7
storage.files.create 389.57
storage.buckets.create 373.25

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a bounded Redis connect/read timeout (_APP_REDIS_TIMEOUT, default 3s) to pooled and publisher connections, plus close-on-connection-error handling in the cache and lock adapters.

Key findings

  • 🟡 Publisher pool misses timed-out replies as transport errors — registers.php:262

Comment thread app/init/registers.php
Comment on lines +262 to 263

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Publisher pool misses timed-out replies as transport errors

The new 3s read timeout on publisher-pool connections (app/init/registers.php:262) can surface as a phpredis 'protocol error' or 'timed out' exception, but isTransportError() in packages/queue/src/Connection/Redis.php does not match either string. call() then rethrows without $this->close(), leaving the dirty socket in the Swoole pool, so the next borrower reads the stale reply as its own response — the same failure mode this PR fixes for the cache and lock packages. Corrupted replies can silently misroute queue publishes and acks.

📋 Prompt for AI Agents

In packages/queue/src/Connection/Redis.php around lines 320-326, update the regex in isTransportError() from '/went away|Connection (lost|closed|refused)|read error on connection/' to also match 'protocol error' and 'timed out'/'timeout' (case-insensitively), matching the connection-error list just added to packages/cache/src/Adapter/Redis.php isConnectionError(). Reason: app/init/registers.php:262 now applies a 3s read timeout to publisher Redis connections, and a timed-out script reply leaves the socket holding an unread reply that must trigger $this->close() in call() or the pooled connection serves a stale reply to the next command.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant