Skip to content

feat: add Redis\Multiplexing adapter over Swoole TCP#70

Merged
loks0n merged 9 commits into
masterfrom
swoole-tcp-redis-multiplex
May 12, 2026
Merged

feat: add Redis\Multiplexing adapter over Swoole TCP#70
loks0n merged 9 commits into
masterfrom
swoole-tcp-redis-multiplex

Conversation

@loks0n

@loks0n loks0n commented May 11, 2026

Copy link
Copy Markdown
Contributor

Summary

A new Utopia\Cache\Adapter\Redis\Multiplexing adapter that lets many concurrent Swoole coroutines share a single Redis TCP connection. Designed for Swoole-based servers that want one Redis socket serving thousands of cooperative coroutines instead of one socket per request.

Includes a small refactor that pulls the Redis cache envelope out into a shared Redis\Envelope codec so the existing Adapter\Redis and the new Adapter\Redis\Multiplexing agree on storage format.

What's in src/Cache/Adapter/Redis/

  • Multiplexing — the cache adapter. Holds at most one published ConnectionContext; a single send-lock serialises pending->push + client->send so the FIFO order of registered response channels matches the order of bytes on the wire and Redis's guaranteed in-order replies pair them correctly.
  • Client — Swoole TCP wrapper plus the RESP2 codec (encode/parse). Owns the socket and any unread byte buffer; handshake leftovers stay in its buffer and are drained by the reader via takeBuffer().
  • ConnectionContext — value object pairing a Client with its pending queue. Per-context state means a context can be torn down on timeout without poisoning a replacement context.
  • Envelope — shared {time, data} JSON envelope (encode/decode/touch with TTL semantics). Used by both Adapter\Redis and Adapter\Redis\Multiplexing.
  • ConnectionException, ConnectionError, RedisError — typed signals so the retry loop can distinguish reconnectable transport errors from server-side Redis errors.

Behaviour highlights

  • Implements Cache\Feature\Telemetry — emits a redis_multiplexing.pending.depth gauge after each enqueue, the single most useful health signal for this adapter.
  • readTimeout defaults to 250 ms — caches should fail fast and let callers fall through to the source of truth.
  • A response timeout poisons that connection. Other in-flight commands on the same connection also see ConnectionException and the next command publishes a fresh context. By design — alternative is per-request resync logic, much more complex.
  • Fixed '0' password bug — both Adapter\Redis and Adapter\Redis\Multiplexing previously skipped AUTH when the password literal was '0' (because empty('0') === true). Both now use a strict null check.

Documentation

  • docs/multiplexing.md — consumer-facing guide: when to use, constructor options, error model, lifecycle, limitations.

Test plan

  • vendor/bin/phpunit --testsuite unit — 46 tests / 77 assertions, no Docker needed
    • 23 Client tests covering RESP encode/parse edge cases, unwrap rethrow at top level and nested in arrays
    • 14 Envelope tests covering shape, TTL boundary, malformed JSON, nested data, touch semantics
  • vendor/bin/phpunit --testsuite e2e --filter Multiplexing — 10 tests / 28 assertions
    • basic ops (save/load/purge/touch/ping/flush/getSize)
    • large and nested JSON payloads
    • 50-way concurrent multiplexing through a single shared connection
  • PHPStan level max + Pint clean
  • Verified locally on PHP 8.3.31, 8.4.21, and 8.5.6 (all on Swoole 6.2.0 via appwrite/utopia-base:*-2.0.0)

Notes for reviewers

  • The test layout has been split into tests/Cache/Unit/ and tests/Cache/E2E/; existing tests moved unchanged under E2E/. The two phpunit testsuites (unit, e2e) let CI run the fast unit suite without Docker if needed.
  • RedisCluster.php and Redis.php were intentionally not renamed in this PR; the new code lives at Adapter\Redis\* (subnamespace) and coexists with the existing top-level Adapter\Redis class.
  • The CI matrix is now ['8.3', '8.4', '8.5'], dropping 8.1/8.2.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented May 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a Redis\Multiplexing adapter that lets Swoole coroutines share a single Redis TCP connection via a FIFO send-lock + per-request response channel design, and refactors the cache envelope into a shared Redis\Envelope codec used by both Adapter\Redis and Adapter\Redis\Multiplexing.

  • All previously flagged issues have been addressed: pop() now uses $this->readTimeout, retries use Coroutine::sleep, the auth path uses a strict !== null check, and the send-failure orphaned-channel race is resolved through teardownIfCurrent → finishTeardown draining the full pending queue.
  • The test layout is cleanly split into unit (no Docker) and e2e suites; the concurrent 50-coroutine multiplexing test gives meaningful confidence in the FIFO invariant under load.

Confidence Score: 5/5

The adapter is safe to merge; the core send-lock + FIFO-channel invariant is correct and all previously raised defects have been resolved in this revision.

The send/enqueue atomicity, per-request read timeout, coroutine-safe retry, and teardown/drain logic are all implemented correctly. The two remaining notes are a test-only blocking sleep and a theoretical channel-closed edge case that is unreachable under current usage.

No files require special attention; Multiplexing.php and Client.php are the most complex additions but both hold up under close scrutiny.

Important Files Changed

Filename Overview
src/Cache/Adapter/Redis/Multiplexing.php New multiplexed Redis adapter; core send-lock + FIFO-channel design is solid, all previously flagged issues (pop timeout, retry sleep, auth empty check) resolved in this version
src/Cache/Adapter/Redis/Client.php Clean RESP2 encoder/parser; INCOMPLETE sentinel is handled correctly by the reader loop, partial-frame re-parse from offset 0 is safe
src/Cache/Adapter/Redis/Envelope.php Shared envelope codec; intentional decode-silent-miss pattern is correct, touch() properly uses JSON_THROW_ON_ERROR
src/Cache/Adapter/Redis.php Refactored to use shared Envelope; auth check now uses strict !== null comparison instead of empty()
tests/Cache/E2E/Redis/MultiplexingTest.php Good E2E coverage including 50-way concurrent multiplexing; one blocking sleep() call inside a Swoole coroutine context should use Coroutine::sleep()
composer.json PHP minimum bumped from 8.2 to 8.3; swoole/ide-helper added to require-dev only (no production impact); CI matrix updated accordingly

Reviews (10): Last reviewed commit: "fix: throw InvalidArgumentException for ..." | Re-trigger Greptile

Comment thread src/Cache/Adapter/RedisMultiplexing.php Outdated
Comment thread src/Cache/Adapter/RedisMultiplexing.php Outdated
Comment thread src/Cache/Adapter/RedisMultiplexing.php Outdated
Comment thread src/Cache/Adapter/RedisMultiplexing.php Outdated
Comment thread src/Cache/Adapter/Redis/Multiplexing.php
loks0n and others added 3 commits May 12, 2026 13:13
- Move every test under tests/Cache/E2E so e2e and unit suites can be
  selected independently.
- Group RedisTest, RedisClusterTest under tests/Cache/E2E/Redis/.
- Update phpunit.xml to expose 'unit' and 'e2e' testsuites; the
  default test command no longer needs to enumerate files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A coroutine-aware Redis cache adapter that multiplexes many concurrent
Swoole coroutines over a single Redis TCP connection. The architecture
is documented in docs/multiplexing.md.

Key components under src/Cache/Adapter/Redis/:

- Client — Swoole TCP socket wrapper plus the RESP2 codec. Owns the
  socket and any unread byte buffer.
- Multiplexing — the cache adapter. Holds at most one ConnectionContext
  at a time; a single send lock serialises pending->push + send so the
  FIFO invariant matches inbound RESP frames to outbound commands.
- ConnectionContext — value object pairing a Client with its pending
  queue. Per-context state means a context can be torn down on timeout
  without poisoning a replacement context.
- Envelope — shared {time, data} cache JSON envelope (encode/decode/
  touch with TTL semantics).
- ConnectionException, ConnectionError, RedisError — typed signals so
  the retry loop can distinguish reconnectable errors from server-side
  Redis errors.

Implements Cache\Feature\Telemetry; emits a
redis_multiplexing.pending.depth gauge after each enqueue, the single
most useful health signal for this adapter.

Tests: 23 RESP codec + unwrap unit tests, 14 envelope unit tests, and
10 e2e tests covering basic ops, large/nested JSON payloads, and 50-way
concurrent multiplexing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Replace per-version Dockerfiles with a single Dockerfile.php-8.4
  built on appwrite/utopia-base:php-8.4-1.0.0 (Swoole 6.1 ships
  Coroutine\Lock, which Multiplexing needs).
- Collapse the CI matrix to 8.4 and bump composer.json to >=8.4.
- Add swoole/ide-helper for phpstan stub coverage.
- Add phpstan.neon scanning the swoole stubs.
- Add docs/multiplexing.md, a consumer-oriented guide.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@loks0n loks0n force-pushed the swoole-tcp-redis-multiplex branch from d58a7bc to ce01efb Compare May 12, 2026 12:15
Replace the inline {time, data} JSON encode/decode/touch in
Adapter\Redis with calls to Adapter\Redis\Envelope. Same logic as
before, except load() now applies the envelope-shape guard the
multiplexing adapter already used — previously Redis::load would
crash on missing keys or non-int 'time'.

Also fix the empty()-based auth check so a literal '0' password is
preserved (same fix Multiplexing already had).

Drop a stray testGetSize pollution in RedisTest by flushing first.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment thread composer.json Outdated
The 2.0.0 utopia-base images all ship Swoole 6.2 with Coroutine\Lock
plus ext-redis, so we no longer need a custom Swoole image. Drop 8.2
(no longer published in the 2.0.0 line), add 8.5, and bump the
php-memcached extension to v3.4.0 (8.5-compatible).

Lower the composer.json floor to >=8.3 to match the matrix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@loks0n loks0n changed the title feat: add RedisMultiplexing adapter over Swoole TCP feat: add Redis\Multiplexing adapter over Swoole TCP May 12, 2026
loks0n and others added 2 commits May 12, 2026 15:41
The previous draft posed the choice as multiplexing vs. a non-coroutine
PhpRedis adapter, but the realistic alternative for Swoole apps is a
pool of phpredis connections (Adapter\Pool wrapping Adapter\Redis).
Reframe accordingly and trim to the parts a consumer actually needs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rename redis_multiplexing.pending.depth → cache.redis_multiplexing.pending.depth
to match the cache.* prefix the existing operation-duration histogram
already uses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment on lines +137 to +139
if (empty($key) || empty($data)) {
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 The same empty('0') footgun fixed in the auth check also affects save(). Use a concrete emptiness check for strings so the value '0' is not silently discarded. Redis.php::save() has the identical pattern and should receive the same fix.

Suggested change
if (empty($key) || empty($data)) {
return false;
}
if ($key === '' || $data === '' || $data === []) {
return false;
}

loks0n and others added 2 commits May 12, 2026 15:49
The previous default of 0.0 with a 'timeout > 0 ? timeout : 1.0'
fallback was confusing — the documented default and the actual
default disagreed. Use 1.0s directly so the constructor signature
matches behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously readTimeout <= 0 was silently coerced to 0.25s. Better
to fail loudly so misconfiguration surfaces during testing rather
than masking the user's intent at runtime. Apply the same rule to
the connect timeout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@loks0n loks0n merged commit fc3b9ae into master May 12, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants