Skip to content

[prototype, for discussion] Add a node-redis backed connection alongside ioredis - #79

Draft
DavideCarvalho wants to merge 1 commit into
adonisjs:11.xfrom
DavideCarvalho:feat/node-redis-connection-prototype
Draft

[prototype, for discussion] Add a node-redis backed connection alongside ioredis#79
DavideCarvalho wants to merge 1 commit into
adonisjs:11.xfrom
DavideCarvalho:feat/node-redis-connection-prototype

Conversation

@DavideCarvalho

Copy link
Copy Markdown

This is a prototype, not a merge request. It exists to make the discussion in #78 concrete. I would rather throw all of it away than have it merged on the strength of "there is a diff now". If the direction or the shape is wrong, saying so is the most useful outcome of this PR.

Refs #78 — "Support node-redis alongside ioredis, now that ioredis is in maintenance mode". That issue ended with "happy to prototype if there's interest"; this is that prototype, built so you have something real to react to instead of a paragraph of speculation.

What this is

A NodeRedisConnection that lives next to the ioredis connections, opted into per connection in config/redis.ts. The ioredis path is not modified: no changes to AbstractConnection, RedisConnection, RedisClusterConnection or io_methods.ts, and the existing test suite passes unchanged.

The async connect problem, and the answer I picked

This is the part worth your attention. node-redis needs an awaited connect(), but redis.connection() resolves synchronously inside the container and constructors cannot be async. That is what stalled the first attempt on the bus side (elee1766 on boringnode/bus#41); niksy's boringnode/bus#61 works around it with lazily created connection promises.

The approach here is the same idea adapted to this package's lifecycle:

The connection object is a synchronously created handle. The client creation and the connect() call live inside a single memoized promise, and every command awaits that promise before running.

connect(): Promise<RedisClientType> {
  if (this.#connection) return this.#connection

  this.#status = 'connecting'
  this.#connection = (async () => {
    const createClient = await NodeRedisConnection.#importClient()
    const client = createClient(this.#config)
    this.#client = client
    this.#monitorConnection(client)
    await client.connect()
    return client
  })().catch((error) => {
    this.#connection = undefined // let a later command retry
    throw error
  })

  return this.#connection
}

// every proxied command goes through it
nodeRedisMethods.forEach((method) => {
  NodeRedisConnection.prototype[method] = async function (...args) {
    const client = await this.connect()
    return client[method](...args)
  }
})

Why this and not the alternatives:

  • Why not connect in the provider's boot()? It would make every app pay a Redis round-trip at boot for connections it may never use, and it would break the lazy-connection semantics ioredis connections have today (#75 explicitly went the other way: don't dial the server for a connection nobody used). The memoized promise keeps "never used, never dialled" true — quit() on a connection that never ran a command closes nothing and just emits end.
  • Why not an async redis.connection()? That is a breaking change to the single most used method in the package.
  • Why memoize rather than if (!client.isOpen) await client.connect()? node-redis throws Socket already opened on a second connect(), and N concurrent commands issued before the first connect resolves would race. The memoized promise makes the first command pay the cost and every later one hit a resolved promise. There is a test for exactly this (three concurrent INCRs on a cold connection return 1, 2, 3).
  • The module import is inside the promise too. redis is an optional peer dependency, so it must never be loaded for apps that only use ioredis. await import('redis') inside the connect promise means a build of this package contains exactly one reference to redis in the emitted JS, and it is that dynamic import. The cost of that choice: .nodeConnection is undefined until the connection is established. await connection.connect() returns the client, so nothing is unreachable, it just isn't synchronously available the way .ioConnection is. If you'd rather have redis as a hard dependency and a synchronously available client handle, that's a one-line change and I'd like to know.

The config surface a user writes

// config/redis.ts
import { defineConfig } from '@adonisjs/redis'

export default defineConfig({
  connection: 'main',
  connections: {
    // unchanged, still ioredis
    main: {
      host: env.get('REDIS_HOST'),
      port: env.get('REDIS_PORT'),
      password: env.get('REDIS_PASSWORD'),
    },

    // opt-in, node-redis
    modern: {
      client: 'node-redis',
      socket: {
        host: env.get('REDIS_HOST'),
        port: env.get('REDIS_PORT'),
      },
      password: env.get('REDIS_PASSWORD'),
    },
  },
})
const connection = redis.connection('modern') // -> NodeRedisConnection, typed
await connection.set('key', 'value', { PX: 1000 })

// downstream packages that want the raw client
const client = await redis.connection('modern').connect() // -> RedisClientType

client: 'node-redis' is the discriminant; GetConnectionType picks the class from it, so redis.connection('modern') is typed as NodeRedisConnection and redis.connection('main') is still RedisConnection. There's a type test for that.

Implemented

  • Connection lifecycle: lazy memoized connect, connect(), quit(), disconnect(), "never dialled → nothing to quit"
  • status tracked by hand in the ioredis vocabulary (wait/connecting/connect/ready/reconnecting/close/end) plus isReady(), isConnecting(), isClosed(), lastError — so code duck-typing on connection.status behaves the same with either client
  • Events through the same Emittery surface: connect, ready, error, reconnecting, end. That is what keeps RedisManager working unmodified — its error reporter and its "delete from activeConnections on end" bookkeeping just work
  • Manager integration: redis.connection(), connection re-use, quit/quitAll/disconnect/disconnectAll
  • ~60 commands proxied (strings, keys, hashes, lists, sets, sorted sets, scripting, server) with node-redis's own signatures, so set(k, v, { PX }) and camelCase, not an ioredis emulation layer
  • publish() (both the promise and the callback overload)
  • defineCommand() / runCommand(), including manager-level scripts registered before the connection exists
  • A RuntimeException with an actionable message when redis isn't installed

Deliberately NOT implemented

Listing these rather than half-doing them:

  • Cluster. node-redis has createCluster, and it would be a second class. Not attempted.
  • Pub/Sub subscriptions. node-redis needs a dedicated client, same as ioredis. subscribe/psubscribe/unsubscribe/punsubscribe exist but throw a clear "not implemented in this prototype" error — they have to exist for RedisManager to typecheck, and I'd rather they fail legibly than as undefined is not a function. publish does work.
  • Health checks. RedisCheck and RedisMemoryUsageCheck take Connection (the ioredis union) and reach into .ioConnection. I did not widen that union, because it would break those classes. A node-redis connection can't be passed to them today.
  • Tracing channels. redisCommand publishes { command: ioredis.Command }. node-redis's sendCommand takes a plain argument array, so wiring it up means deciding what the channel payload should look like for a non-ioredis client. That's a design question, not a coding one.
  • The full command surface. ~60 commands vs ioredis's enumerated ~500. Anything missing is reachable via await connection.connect().
  • Sentinel, RESP2 pinning, client-side caching, pooling (createClientPool), and the scripts/functions options node-redis takes at client creation.
  • configure/stub changes. The generated config/redis.ts still only mentions ioredis.

API differences I actually hit

  1. camelCase + options objects. set(k, v, 'PX', 1000) becomes set(k, v, { PX: 1000 }); hsethSet, pttlpTTL, setnxsetNX. There is no compatibility alias in node-redis. So a "client-agnostic surface" (issue question 3) can't be a thin rename — it would have to be a real translation layer, and I don't think a prototype should smuggle one in.
  2. connect() doesn't reject while the server is unreachable. With the default reconnect strategy, node-redis retries forever and the connect() promise never settles — so a command awaiting it hangs, where ioredis fails it via maxRetriesPerRequest. Users need socket.reconnectStrategy / connectTimeout to get a failure. This is the sharpest behavioural difference for AdonisJS apps and I think it deserves a documented default rather than being left to each app.
  3. No close event, and reconnecting carries no delay. I emit reconnecting with waitTime: 0 to fit the existing ConnectionEvents shape, and never emit close/wait. node:* and subscriber:* are never emitted either.
  4. RESP3 is the default in node-redis v6, whereas ioredis speaks RESP2. Reply shapes can differ for some commands. Something to decide before this becomes a supported path.
  5. Lifecycle method mismatch. quit() is deprecated in favour of close(), disconnect() in favour of destroy(), and both throw ClientClosedError on a client that was never opened — hence the explicit "never dialled" branch.
  6. Lua scripts. ioredis's defineCommand attaches the script to the client; node-redis takes scripts at client creation only. runCommand therefore EVALs the stored script, splitting args by numberOfKeys.

Public type impact

One widening, and one finding worth flagging:

  • RedisManager's connection event payload is now RedisConnection | RedisClusterConnection | NodeRedisConnection. A listener that reaches for .ioConnection without narrowing would need a check. I couldn't see a way to avoid it that doesn't lie about runtime (the alternative — casting at the emit site — silently hands you a RedisConnection that isn't one). Happy to make it generic over the app's declared connections instead if you'd prefer strict non-breakage; it costs a conditional type for the "no connections declared" case.
  • RedisConnectionsList, GetConnectionType and RedisManagerFactory's constraint were widened (additive; existing configs resolve to exactly the same classes as before). Connection, .ioConnection, io_methods.ts and every existing export are untouched.
  • redis as an optional peer + import type in the shipped .d.ts: build/src/types.d.ts and build/src/connections/node_redis_connection.d.ts reference redis types. I checked what that means for an app that doesn't install redis: AdonisJS's own tsconfig sets skipLibCheck: true, so the unresolved import is silently ignored and those types degrade to any rather than erroring (I verified both directions — with skipLibCheck: false it's a TS2307). So it's safe in practice for AdonisJS apps, but it's a real constraint on the design and you may prefer hand-rolled structural types, or a hard dependency.

Tests

Run against the same Redis the existing suite uses (compose.yml / the grokzen/redis-cluster service with STANDALONE=true on 7007), no new provisioning:

Existing suite, before this branch:  80 passed
Full suite, on this branch:         103 passed  (80 existing + 23 new)

npm run lint, tsc --noEmit and npm run compile all clean. The 23 new tests cover the lazy-connect behaviour, connect memoization under concurrency, connection failure and error propagation, quit/disconnect/post-close use, the command subset, the options-object form, lua scripts, and manager-level integration and type inference.

What I'd like to know

  1. Is an additive second implementation the direction you want at all, or is staying on ioredis a deliberate call? (Nothing here is worth reviewing in detail if the answer is the latter.)
  2. Is the memoized-connect-promise answer to the async lifecycle acceptable, or do you want the connection established at provider boot / behind an explicit await in userland?
  3. .ioConnection vs .nodeConnection: parallel accessors (what this does), or is there appetite for a client-agnostic surface? Given difference [Feature Request] #1 above, I don't think the latter can be cheap.
  4. Optional peer dependency (this PR) or hard dependency on redis?
  5. If the direction is right, what should the next slice be — cluster, pub/sub, or health checks?

Happy to rework, split, or close this.

Adds a "NodeRedisConnection" alongside the existing ioredis connections,
opted into per connection using "client: 'node-redis'" in the redis
config file. The ioredis code path is left untouched.

node-redis requires an awaited "connect()" call, which cannot happen
inside a constructor resolved synchronously by the IoC container. The
connection object is therefore a synchronously created handle, and the
client creation plus the "connect()" call live inside a single memoized
promise that every command awaits before running.

This is a prototype for the discussion in adonisjs#78, not a merge ready
feature. Cluster, pub/sub subscriptions, health checks, tracing
channels and the complete command surface are deliberately not
implemented.
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.

1 participant