Skip to content

feat: add Redis-backed editor data storage for high availability - #46

Open
nvanlaerebeke wants to merge 62 commits into
Euro-Office:mainfrom
nvanlaerebeke:feature/redis-editor-data-storage
Open

nvanlaerebeke wants to merge 62 commits into
Euro-Office:mainfrom
nvanlaerebeke:feature/redis-editor-data-storage

Conversation

@nvanlaerebeke

@nvanlaerebeke nvanlaerebeke commented Sep 18, 2026 •

Copy link
Copy Markdown

Summary

This pull request adds an optional Redis-backed editor-data implementation for the Euro Office Co-Authoring service. It is intended to allow multiple Document Server instances to share the state required for high-availability
deployments.

The implementation covers the shared state used by Co-Authoring, including:

  • editor presence and connection membership
  • document locks and lock cleanup
  • co-authoring messages and chat-related state
  • force-save state and coordination
  • saved-state tracking and recovery
  • editor connection and usage statistics
  • graceful shutdown and failure recovery

Redis is used for shared editor-data and statistics state.
RabbitMQ remains responsible for the existing pub/sub and task-queue mechanisms.

The backend is disabled by default. It can be selected through the existing configuration:

{
  "services": {
    "CoAuthoring": {
      "server": {
        "editorDataStorage": "editorDataRedis",
        "editorStatStorage": "editorDataRedis"
      }
    }
  }
}

The Redis connection layer supports standalone Redis, Redis Cluster, and Redis Sentinel, including authenticated, unauthenticated, and ACL configurations.

The same topology support is available for Valkey. Configuration is normalized for the node-redis client, with separate options for Redis nodes and Sentinel nodes.

Implementation details

  • Editor-data and editor-stat concerns are split into independently testable modules, with shared connection ownership and lifecycle management.
  • Redis keys use the configured prefix and encode tenant/document identifiers.
    Cross-document indexes are distributed over fixed shards; related keys use
    Redis Cluster hash tags so operations remain slot-compatible.
  • Presence, locks, messages, force-save state, saved state, and statistics use explicit TTLs. Expiration is processed through bounded, atomic Lua scripts with leases/claims so cleanup remains retry-safe and cannot grow into an
    unbounded single operation.
  • Connection failures, command timeouts, reconnects, shutdown races, and partial state loss have explicit recovery and failure policies. Redis time is used for expiry-sensitive presence handling, and saved-state acknowledgements
    and force-save updates are made idempotent.
  • Redis Cluster and Sentinel topology handling includes slot-aware key layout, bounded Sentinel discovery/reconnect behavior, client-generation isolation, and promotion/reconnection coverage.

This work depends on Euro-Office/server#52, which adds lifecycle management for the runtime configuration watcher.
Redis shutdown and reconnect paths made the existing watcher/timer cleanup issue observable; without #52, those resources could survive shutdown or cause a reload during teardown and make process/topology tests hang or fail.

One known limitation remains: Redis/Valkey replication failover is not fully lock-safe. If a primary fails immediately after granting a lock, a promoted replica may not yet contain that lock. Sentinel support, retries, and WAIT cannot eliminate that replication window, so deployments should not treat replication failover as a strict lock-safety guarantee.

Testing

The test suite now includes:

  • contract, module-boundary, key/codec, configuration, and health-check tests
  • presence, locks, messages, force-save, saved-state, statistics, and conditional document-cleanup tests
  • TTL, expiration queues, shard expiration, bounded cleanup, retry-safe claims, and garbage-collection tests
  • command-timeout, reconnect, client-isolation, connection-failure, and data-loss recovery tests
  • independent-process tests and differential checks against the in-memory backend
  • Cluster slot distribution, failover/promotion, and reconnection tests
  • Sentinel discovery, failover, reconnect, and configuration tests
  • packaged-install smoke/e2e coverage that loads editorDataRedis through the installed configuration and verifies a live Redis connection
  • runtime configuration watcher and document-service shutdown regression
    tests introduced by the prerequisite in server#52.

The GitHub Actions coverage runs the Redis editor-data suite against:

  • Redis 7.0.15, 7.2, and 8;
  • Valkey 7.2 and 8;
  • standalone and Cluster topologies; and
  • Sentinel with password, unauthenticated, and ACL authentication modes.

The regular service unit-test workflow also runs the Redis suite, and the packaged end-to-end workflow runs the Redis smoke test against the packaged server image. Local test instructions and topology setup are documented in server/tests/redis/README.md.

Review notes

The implementation started as an AI-assisted prototype and has since been substantially refactored, reviewed, tested, and hardened through the work described above.
It is submitted for review as a candidate implementation of Redis-backed high availability for
Co-Authoring.

@moodyjmz

Copy link
Copy Markdown
Member

Hi, this is an interesting PR and converges with #44. I feel 44 has more depth, but this is wider in feature set. They also use differing libraries, node-redis and ioredis. Being as your submission is AI, I would take the opportunity to run AI to compare both PRs. My questions are around depth, features and libs. I will update here once I have done that.

@moodyjmz moodyjmz added the enhancement New feature or request label Sep 21, 2026
@nvanlaerebeke

Copy link
Copy Markdown
Author

FYI, the reason I didn't use ioredis was that the node-redis is I think the "more" official one.

Updating that package to 5.x also gives support for redis sentinel etc and I thought it wasn't productive to have multiple packages that basically do the same.

I'll see if I can bring this PR closer to #44

@moodyjmz

Copy link
Copy Markdown
Member

Hi @nvanlaerebeke I would agree having multiple packages doing the same is not productive. I think I would also agree with you about a shift to node-redis and would be more of the mind to change 44 to use that than vice versa.

@moodyjmz

Copy link
Copy Markdown
Member

@nvanlaerebeke Would it be useful for you if I run a comparison and share it? (44 and 46 - they point at different things, but might be useful and I can burn some tokens so you don't have to)

@moodyjmz

Copy link
Copy Markdown
Member

@nvanlaerebeke @MonaAghili — comparison of #46 against #44, read at cf0d7200. There are three independent efforts on this now, and the technical content below is the part that matters regardless of which base is chosen.

Convergent

Four non-obvious editorDataMemory semantics, each a trap a straight port falls into, all handled correctly in #46:

  • _checkAndLock is re-entrant with TTL refresh. A plain SET NX denies the current holder, and the client re-locks on every save cycle, breaking roughly every second save. The LOCK script handles it.
  • lockNotification relies on NaN !== NaN for non-re-entrancy. Routed through a shared owner-comparing script the token serialises to "NaN", which equals itself, and the throttle stops throttling. SET NX PX is correct.
  • addForceSaveTimerNX is first-write-wins. A plain ZADD slides the autosave deadline forward under continuous editing so it never fires. ZADD NX is correct.
  • Unlock returns the numeric c_oAscUnlockRes enum and callers branch on the number. tonumber is correct.

A clean-room derivation from editorDataMemory produced the same four independently; #44 implements two and delegates two. Also convergent: Redis with Lua for compare-and-set, one hash tag per document, encoded key components, shutdown key used verbatim without re-prefixing or tenant-scoping.

Divergent

Surface. #46 implements the full surface including EditorStat. #44 Redis-backs ten EditorData methods and delegates seventeen. Neither has per-method failure-path coverage or a two-process suite; #46's tests are integration-shaped over the full surface, #44's unit-shaped over ten methods.

#44's partial state is not merely incomplete, it is harmful: isSaveLock (DocsCoServer.js:3700, :3705-3714) compares a client's document-wide change index against the force-save record's index and denies the save lock until the connection's unsynced timestamp is expire.saveLock old, warns at :3716, resets at :3725. With force-save state per-replica and locks shared, every replica compares against a record another replica armed. Sharing part of the surface is worse than sharing none.

Test infrastructure. redisEditorDataTests.yml stands up a real cluster in CI — a redis:7-alpine service for standalone, plus six nodes in run steps with --cluster-enabled yes and redis-cli --cluster create --cluster-replicas 1. #44 has no cluster tier; none of the twelve workflows on its branch mentions redis or a cluster. verify.js is client-agnostic at the EditorData API and portable to either implementation.

#44's redis suite does run in CI inside the existing unit job against an ephemeral server, and #44 has additionally been exercised against a real multi-node cluster under sustained multi-replica load outside CI. A cluster CI job establishes that the code runs correctly on a cluster; a field run establishes that it survives one. Both are needed.

Failure policy. #46 throws from every method with no call site changed (16 files, none of DocsCoServer.js, canvasservice.js, gc.js). One path interacts badly: a throw inside commandSfcCallback after the file is assembled leaves the document row at SaveVersion/UpdateVersion, createSaveTimer will not re-arm because its mask requires Ok (DocsCoServer.js:1558), and receiveTask acks regardless (canvasservice.js:2088-2092). What re-drives a row left in those states is not established. #44 returns decided values — false from lock(), LOCKED from unlock() — matching callers written for values rather than exceptions. Neither branch justifies the per-method return at the call site, which is the open gap.

Findings

1. Both cross-document indexes are on one slot. {editor:index}:documents and {editor:index}:forcesavetimer (editorData.js:40-41) both hash to slot 1073. Every document in a deployment writes index entries to one master; losing that master stalls expiry and force-save deployment-wide. Throughput is not the issue — one ZADD per connection per documentsCron tick and a single-key pop. The issue is that the key cannot be scaled out and its failure domain is the whole deployment. #44 shards the equivalent sixteen ways over a hash of (tenant, docId), keyed on both so a single-tenant deployment does not collapse into one shard. Transferable regardless of client library.

2. The command timeout outlives the transport. base.js:61-62 sets 30 s command, 15 s connect. The socket dies at around 25 s (comment at the saveChanges lock site in DocsCoServer.js), so a stalled command outlasts the connection it belongs to.

3. Sentinel deployments run standalone silently. The orchestrated entrypoint emits sentinel configuration only under iooptions (build/scripts/orchestrated/docker-entrypoint.sh:132-138, DocumentServer repo) — sentinels, group name, sentinelPassword. #46 removes that block and never reads it, so a sentinel-configured operator gets a standalone connection and no error.

The reverse applies to #44: the standalone entrypoint writes credentials only under redis.options (entrypoint.sh:211-213, as username/password/database) and #44 never reads options, so it attempts to connect without credentials — on a protected server that fails AUTH and fails closed. Both are config plumbing. normalizeNodeOptions is the better of the two answers and transfers as-is.

4. disableOfflineQueue is never set, so it sits at node-redis's default of on: during a reconnect after a socket drop, commands queue in memory rather than failing fast, and a caller that should have been told "no" waits. #44 sets the ioredis equivalent off deliberately. On a cluster it must go in defaults rather than rootNodes, since per-node connections do not inherit rootNodes configuration.

Destroying the client on timeout is correct and #44 has no equivalent. It is the only mechanism that turns a black-holed connection into a reconnect: when a peer dies without TCP teardown — SIGKILL, an idle drop in a NAT or load balancer, a partition — there is no FIN and no RST, the socket reads as open, the reconnect strategy never runs, and detection falls to TCP keepalive minutes later. Without _abortClient() every later command is written to the same dead socket and fails at the deadline for that window. With it, the next command rebuilds inside one connect budget, and on a cluster re-discovers slots by the same path. Its cost is collateral rejection of concurrent in-flight commands when the connection was healthy and one command was slow; the lever for that is the budget (finding 2) and the hot index (finding 1), not the mechanism.

ioredis's commandTimeout only rejects the promise — Command.js:191-197, setTimeout → this.reject(new Error("Command timed out")) — and touches nothing on the connection. #44 sets no reconnectOnError. So #44 currently has the half-open exposure with no recovery, and existing field measurements could not have surfaced it because no node died during them. On migration #44 needs this mechanism.

5. POP_EXPIRED (scripts.js:95-101) removes entries before the reply reaches the caller, so a lost reply is a claimed-then-discarded batch. #44's sweep has the same defect in a different form; it should be fixed once.

Client library

node-redis, per Redis's own recommendation for new projects.

Version: 6.x, with one decision to make explicitly. Current is 6.2.1; the 5 line is at 5.12.1; 4.7.1 is tagged maintenance-v4. 4.7.0 has no sentinel — it exports createClient and createCluster only — and sentinel is a supported topology, so createSentinel in 5 is the floor. 6.0 makes RESP3 the default protocol, which needs an explicit RESP: 2 or a measured decision rather than silent inheritance; an equivalent ioredis 6 bump was deferred for the same reason. 6.2 also fans out cluster multi-key commands across slots transparently, so a cross-slot key layout stops announcing itself — slot assertions in tests should check the slot directly rather than infer safety from a command succeeding.

withTimeout is not redundant on 6.x. commandOptions.timeout applies only to commands not yet written to the socket. Once a command is on the wire it has no per-command deadline. socketTimeout is not a substitute: it is an idle timeout, fires on a healthy idle connection, and the default reconnect strategy then closes the client permanently. Measured on 5.12.1 — timeout: 300, in-flight GET under CLIENT PAUSE 1500, resolved at 1599 ms. The queued window is covered natively; the in-flight window only by a caller-side race. The 30 s budget is the part worth revisiting (finding 2); while _abortClient() is on that path, the budget is also the blast radius.

Cluster options belong in defaults, not rootNodes. Per-node connections do not inherit rootNodes config; credentials, TLS and timeouts must be in defaults to reach every connection. ioredis hardcodes the equivalent, node-redis leaves it configurable.

Command resend. node-redis does not replay a command already written — on socket close, sent commands reject rather than re-running — so the "was the lock actually taken?" hazard is closed on standalone and cluster without an option, where ioredis needed one.

The exception is sentinel. RedisSentinel.execute re-runs a master command that rejected while the client is not ready, up to maxCommandRediscovers (default 16), and a command written to a socket that then closes rejects exactly that way. The logic is unchanged between 5.12.1 and 6.2.1. 6.2.1 does fix the adjacent case — a lost master no longer holds the caller indefinitely, rejecting after the same bound — which is a further argument for 6. maxCommandRediscovers: 0 disables the re-send, at the cost of failing in-flight master commands during a failover rather than retrying; on a lock path that is the wanted behaviour.

This lands harder on #46's surface than #44's. A re-run is harmless where the operation is idempotent, and most of #44's ten are — a re-granted re-entrant lock, a repeated presence write. On the full surface it is not: incrEditorConnectionsCountByShard double-counts, feeding licence counting, and addMessage RPUSHes a duplicate. This is from reading the client source, not running it; neither branch has a sentinel test rig, and #46's cluster job is the pattern for building one.

Evidence. Existing field measurements were taken against ioredis on a real cluster and do not describe a node-redis build. Equivalents are now identified for the offline queue, the resend behaviour and the timeout, which scopes re-measurement to MOVED following and the in-flight window rather than the whole failure contract.

Cluster readiness has no client-side signal: isOpen is true from the start of connect(), before slot discovery, so a health check needs its own flag. (Keep the existing 'error' listeners; without one the emitter throws and the uncaughtException handler exits the process.)

Config surface. #46 removes the iooptions* block, #44 uses it, and an entrypoint change in the DocumentServer repo (PR #363) emits one of the two shapes. Whichever lands, that PR has to match it.

Open, not delivered

  • A call-site audit exists and is not yet public: twelve places where a wrong answer from this store causes an irreversible action — deleted change history, a stub force-save record overwriting a real one, a client told a lock is released while it stands. Derived from editorDataMemory and its consumers, so independent of both implementations, and it is what a per-method failure policy should be justified against.
  • The definition of done is spread across several documents. One requirement neither branch meets: a suite exercising presence, locks, messages, force-save and the mutexes across at least two processes sharing one Redis, with the in-memory backend as a differential oracle. verify.js is close in shape but runs in-process.
  • Fail-open versus fail-closed per method is undecided and blocks finishing the failure policy on either branch.

Neither implementation has been read line by line beyond the areas above, and #46's cluster job has not been run here, so the findings are for verification rather than acceptance.

@MonaAghili

Copy link
Copy Markdown

Independent deep-dive on #46 against editorDataMemory.js as the behavioral baseline, cross-checked with @moodyjmz's comparison above (read at cf0d7200) and a second, independently-derived review pass. Reviewed at acc43970 (current head). Read-only analysis, no code changes.

Where this overlaps with the comment above, I've marked it; the rest is new.

Summary

The hard behavioral work is largely right: lock re-entrancy, the numeric unlock enum, SET NX PX for lockNotification, ZADD NX first-write-wins for the force-save timer, and the presence version-token protocol are all implemented correctly and hold up under the standard race traces (two replicas racing a lock, renew-then-release, crash-then-delayed-unlock). None of that is in doubt.

But five separate things block merge, three of which aren't in the discussion yet:

Blockers

1. Issue #267 itself is not demonstrably fixed. The new module is split into six files under DocService/sources/editorDataRedis/ (base.js, core.js, editorData.js, editorStat.js, index.js, scripts.js), but DocService/package.json's pkg.scripts (and the matching lists in FileConverter/package.json and AdminPanel/server/package.json) still name only editorDataRedis.js. None of the five new files are listed, and no CI job builds or runs a pkg binary with editorDataStorage: "editorDataRedis". Issue #267 is a MODULE_NOT_FOUND in a pkg binary — this PR fixes the source-tree gap but leaves the packaging gap completely untested. tests/redis/editorDataRedis.pkg-smoke.js requires from the source tree and never touches a built binary despite its name. #44, for comparison, lists all 8 new files in pkg.scripts explicitly.

2. deleteKey is implemented against a key this module doesn't own.

// editorStat.js:228-230
EditorStat.prototype.deleteKey = async function (key) {
  await this._command(['DEL', key]);
};

editorDataMemory's deleteKey is a documented no-op. This implementation does a real, unconditional DEL on whatever key the caller passes, unprefixed, in whatever logical DB REDIS_SERVER_DB_KEYS_NUM names — a database this codebase has no ownership of and no way to know the layout of. Call sites pass bare document ids (DocsCoServer.js:1531, :2191) or a task-result mask key (canvasservice.js:495). If that shared logical DB is ever used by another component whose keys happen to collide with a document id, this silently deletes someone else's data with no audit trail. It exists only because contract.tests.js:37 asserts method-name parity with editorDataMemory, which forces Redis to grow some implementation to pass the test — even though the safe answer here is to not implement it at all, matching memory's no-op.

3. Force-save Lua scripts cjson.decode/cjson.encode the whole record on every state transition.

scripts.js:157  local ok, value = pcall(cjson.decode, raw)
scripts.js:164  local updated = cjson.encode(value)

This round-trips the entire changeInfo/convertInfo payload through Lua's JSON codec on every force-save transition. Three concrete corruptions follow: an empty Lua table is ambiguous, so any empty array nested inside changeInfo/convertInfo gets re-encoded as an empty object instead of an array; numbers get truncated at cjson's 14 significant digits; and START_FORCE_SAVE_SCRIPT additionally does value.convertInfo = nil unconditionally on every start, dropping the field entirely rather than preserving it the way editorDataMemory.js:208-217 does. That last one is currently masked because today's call sites happen to already have convertInfo == null at that point, but it's a landmine for the next caller that doesn't. Keeping this payload opaque (store it as an untouched string field, only comparing/writing the small scalar fields in Lua) avoids all three failure modes at once.

4. Sentinel support is silently inert. The orchestrated DocumentServer entrypoint writes sentinel settings under iooptions (docker-entrypoint.sh:132-138). This PR removes iooptions from config and reads a new, differently-shaped optionsSentinel block instead (base.js:71). A sentinel-configured deployment gets a plain standalone connection with no warning at any log level — it looks healthy until the master fails over, at which point every write starts rejecting with READONLY.

5. cleanDocumentOnExit can leave a document's full state behind for up to 7 days, and removeLocks can silently fail to release a lock.

  • cleanDocumentOnExit (scripts.js:282-296) is a no-op — deletes nothing, not even locks/messages/saved-status — if any live presence entry remains (HLEN(presence:hash) > 0), unlike editorDataMemory.js:234-243's unconditional delete. A ghost presence entry from a dead replica is enough to trigger this, and the document's index entry may already be consumed by that point, making it invisible to gc going forward.
  • removeLocks (scripts.js:129-136) deletes by comparing the stored value byte-for-byte against the caller's JSON-stringified copy, not by lock id like editorDataMemory.js does. If a lock is mutated between read and remove (e.g. _recalcLockArray rewriting lock.block.* on a spreadsheet row/column insert, DocsCoServer.js:3595-3603), the comparison fails silently and the lock is never released — the client is told it's free while Redis still holds it.

Also worth flagging (not blocking, but real)

  • POP_EXPIRED_SCRIPT (scripts.js:95-101) has no batch cap — after an outage/backlog it can pop tens of thousands of expired members in one EVAL, blocking the single-threaded server for the duration. A LIMIT-bounded pop (gc's next tick collects the remainder) closes this.
  • Shard staleness silently reuses the presence-expiry setting instead of its own config knob — an operator tuning presence TTL for its stated purpose unknowingly also changes how fast a crashed replica's connection counts decay from /info. They happen to share the same default today, but that's coincidence, not design.
  • addPresence/updatePresence write the global index (ZADD) as a second, non-atomic round trip after the main script — a crash in that window leaves live presence untracked by the index.
  • Client/topology: this PR swaps in node-redis 6 with Cluster/Sentinel support — a real scope and dependency change (pulls in @redis/search, @redis/bloom, @redis/json, @redis/time-series) that the maintainer thread is already discussing. Worth an explicit decision recorded somewhere before merge, since it's the fork everything else (finding 4 above included) hangs off of.
  • 3DPARTY.md wasn't updated for the redis 4.7.0 → 6.2.1 swap and doesn't list the new @redis/* sub-dependencies it pulls in.
  • Installed node_modules/redis in this checkout is still 4.7.0 against a 6.2.1 manifest/shrinkwrap — redis.createSentinel doesn't exist at 4.7.0, so the sentinel path can't load locally; worth confirming what version any "tests passed" run actually executed against.

Test gaps

  • contract.tests.js only checks method name/arity parity across backends, never behavior — this is literally what forces deleteKey to exist. A real shared behavioral contract would have caught the deleteKey, removeLocks, cleanDocumentOnExit, and convertInfo issues above.
  • No multi-process test exists anywhere in either PR — verify.js's concurrency races run several clients in one Node process, which doesn't reproduce two independent DocService replicas (independent crash behavior, independent SHARD_IDs, independent gc timers).
  • No real-server sentinel test at all (only mocked option-normalizer unit tests) — this is what let the sentinel config mismatch through.
  • CI runs redis:7-alpine only; worth adding a Valkey leg given that's the actual deployment target, and cjson's empty-table behavior is build-dependent.

Bottom line

Competent and the most complete of the candidates — full EditorData/EditorStat surface, no call-site changes, real Redis Cluster CI job, and it gets the non-obvious editorDataMemory semantics right where a naive port wouldn't. But it doesn't yet close the issue it's filed against, implements a method against a key it doesn't own, uses a serialization pattern that can silently corrupt force-save state, and has two silent behavioral regressions (cleanDocumentOnExit, removeLocks) in exactly the paths that matter for correctness. Needs another pass before merge — the packaging/deleteKey/cjson issues are all straightforward fixes; the client-library/topology question needs an explicit ruling first, since it changes the shape of a good chunk of the rest.

@nvanlaerebeke

Copy link
Copy Markdown
Author

I've pulled in the #44 yesterday and applied the changes from this pull request on it.

Still have to review it and I'll take the posted comments into account as well, that way it starts from the #44 pull request as a base, that will be for this evening though.

@moodyjmz

Copy link
Copy Markdown
Member

@nvanlaerebeke @MonaAghili I will run a check against Mona's deep dive - the referenced issue is not actually in this repo. It is here: Euro-Office/DocumentServer#267

@nvanlaerebeke thanks for reacting so quickly to replies here, really appreciate it

@moodyjmz

Copy link
Copy Markdown
Member

@nvanlaerebeke @MonaAghili — both reviews verified against acc43970. Claims in each do not survive at that head, including most of the client-library section of my earlier comment, which was read at cf0d7200.

If you are working on this tonight

You said you had pulled #44 and were applying this on top. Four things that are worth having before you start, rather than after.

Sign off while you rebase. DCO is failing on all four commits and it is the only completed check on this PR. git rebase --signoff folds it into the pass you are already doing; found later it costs a second history rewrite.

Client library is settled: node-redis. #44 moves to it. You should not end up carrying both clients through the rebase.

Do not spend time on these — each is something one of the two reviews above could reasonably send you doing, and none of them is needed:

  • listing the six files under editorDataRedis/ in pkg.scripts (pkg traces the shim; verified by execution)
  • making deleteKey a no-op (it is the only reason its proxy exists; it is a decision about the env var, not a defect)
  • reverting cleanDocumentOnExit to an unconditional delete (the HLEN guard is correct multi-replica behaviour)
  • removing withTimeout because 6.x has a native timeout (it covers queued commands only)

CI is now released. Checks on this PR had never run — they were held behind the fork-approval gate. Both are approved and executing against acc43970 as of this comment, so results will appear above shortly. Anything red there is real, not a gating artefact.

If you only get to a few things: removeLocks (defect 1) and the sentinel config shape (defect 2) are the two that are wrong rather than incomplete.

TL;DR

  1. acc43970 predates my earlier comment by ~14 hours. The PR was already on node-redis 6.2.1 with createSentinel; that comment's version section is void. Corrections below.
  2. No CI had ever executed for this PR — every check on all four commits sat action_required behind the fork gate. Now approved and running against acc43970. The one check that had completed is DCO, failing: no Signed-off-by on any commit.
  3. Suite now run here: 41/41 standalone, 41/41 on a real six-node cluster built from this PR's workflow recipe. Node 20.20.2, node-redis 6.2.1, Redis 8.10.2.
  4. The six files under editorDataRedis/ do not need listing in pkg.scripts.
  5. Client library: node-redis. Settled; the rebase proceeds on that basis.
  6. Six defects below. Fail-open versus fail-closed is open, with options set out rather than ruled on.

Corrections — my earlier comment

Claim Status at acc43970
"4.7.0 has no sentinel; the floor is node-redis 5" Void. Already 6.2.1 with createSentinel. Only the RESP pin remains.
F2 "the socket dies at around 25 s" Wrong. The 25 s comments (DocsCoServer.js:3333, :3537) are the browser sockjs connection, not the Redis transport. Constants (30 s command, 15 s connect) correct; 30 s now also overrides node-redis 6.2.1's 5000 ms default on all topologies.
F3 sentinel Substance stands, description obsolete. No sentinel code existed at cf0d7200; at acc43970 it exists with a config shape that does not match the entrypoint. The review above is current.
"Cluster readiness has no client-side signal" Addressed at acc43970 — isReady awaited on all topologies.
"#44 lists 8 files, #46 lists one" 7 files, and the comparison is void — pkg traces #46's shim.
"#46's cluster job has not been run here" Now run.

Unchanged: slot 1073 (re-confirmed by CLUSTER KEYSLOT on the live cluster), disableOfflineQueue unset, POP_EXPIRED, sentinel re-send bounded at maxCommandRediscovers 16, withTimeout not redundant on 6.x (commands-queue.js:409-436 removes the timeout listener at socket-write time).

Corrections — the review above

B1, pkg.scripts. Listing the six files is unnecessary. Verified by execution with @yao-pkg/pkg 6.22.0 (the packer e2e.yml:92 uses): a shim in pkg.scripts has its literal require('./editorDataRedis/index.js') traced transitively and every file included; a control with a non-literal require fails MODULE_NOT_FOUND.

#267's stack fails in Common/sources/notificationService.js:36 — a dynamic require crossing from Common into DocService/sources/ — and Common/package.json has no pkg block. That is a separate bug, independent of both PRs and of which backend ships, and warrants its own issue against DocumentServer.

Neither PR is scoped to #267. A complete, tested Redis backend answers it; the packaging gap is fixed on its own terms. What belongs in this PR is the demonstration: boot a packaged DocService with editorDataStorage: "editorDataRedis" in the existing E2E pkg step.

(A bare #267 in this repository resolves to Euro-Office/server#267, which does not exist; it needs the DocumentServer/ prefix.)

W4, dependency growth — inverted. origin/main already resolves @redis/search, @redis/bloom, @redis/json, @redis/time-series and @redis/graph via its existing redis@4.7.0. 6.2.1 drops graph; this PR drops ioredis. The tree shrinks.

B3, cjson — three mechanisms reproduce, none has a live trigger. Empty-array-to-object and 16-digit truncation reproduced directly, with identical output on Redis 8.10, redis:7-alpine and Valkey 8.1 — not build-dependent, which also removes the stated rationale for a Valkey CI leg. changeInfo contains no arrays, InputCommand has no array fields, time is 13 digits. The convertInfo = nil divergence (scripts.js:163 vs editorDataMemory.js:208-217) is real and worth fixing as a latent trap.

B2, deleteKey — facts confirmed, severity overstated. editorStat.js:228-230 performs a real DEL where memory's is a no-op; five call sites, not three. But editorStatProxy is constructed only when REDIS_SERVER_DB_KEYS_NUM is set (DocsCoServer.js:156-159), deleteKey is the only method invoked on it, every call is gated by preStopFlag or follows a cache purge, and no shipped entrypoint sets that variable. The proxy exists to perform that delete. This is a decision on whether to keep the hook — a no-op makes the env var dead config — not a correctness defect.

B5a, cleanDocumentOnExit — divergence confirmed, trigger overstated. The script sweeps expired presence (scripts.js:283-289) before the HLEN guard, and the guard is correct multi-replica behaviour: an unconditional delete would drop another live replica's locks. The orphan state requires an index score older than a live presence score, via the non-atomic index write or clock skew. The 7-day bound is correct.

W6, installed 4.7.0 — local to that checkout; the shrinkwrap pins 6.2.1 and the workflow runs npm ci. The underlying point, that no run had been verified, was correct and is now answered.

Confirmed as stated: B5b removeLocks, B4 sentinel config shape, the POP_EXPIRED batch cap, the shard-staleness TTL reuse, the non-atomic index write.

Contradictions between the two reviews

Topic Resolution
pkg.scripts Do not list the six files; pkg traces the shim.
Sentinel The review above is current; my "no sentinel support" described the older head.
withTimeout Keep it. 6.x's native commandOptions.timeout covers queued commands only.
cleanDocumentOnExit Do not revert to an unconditional delete; fix the orphan path.

Remaining work

Defects:

  1. removeLocks — compare by lock id (HDEL), not by serialised value. _recalcLockArray mutates locks between read and remove and removeUserLocks publishes releaseLock regardless, so a client is told a lock is released while Redis holds it.
  2. Sentinel config shape — either this PR reads iooptions or the DocumentServer entrypoint emits optionsSentinel. One side moves, and a sentinel-configured deployment that lands standalone must fail loudly.
  3. DCO sign-offs on all commits.
  4. POP_EXPIRED — bound the batch and stop losing entries on a lost reply; one fix.
  5. Force-save Lua — keep the payload opaque, compare and write scalar fields only, stop clearing convertInfo on start.
  6. 3DPARTY.md:49 still records 4.7.0.

Not demonstrated rather than broken: #267 end-to-end (boot a packaged DocService with editorDataStorage: "editorDataRedis"); a real-server sentinel test; a multi-process differential suite. None of the three is covered by the jobs now running.

Client library

node-redis. Redis recommends it for new projects, two packages doing the same job is not worth carrying, and this PR is already there. #44 moves. The rebase should not end up carrying both clients.

Open under it: node-redis 6 defaults to RESP3 (DEFAULT_RESP = 3). This PR runs RESP3 unpinned and passes 41/41, but RESP3 against a cluster is unmeasured, so an explicit RESP: 2 is the conservative setting until it is.

Open: fail-open versus fail-closed

EditorData has 29 methods and a Redis failure means something different in each. editorDataMemory never fails, so the contract is silent on it. Three options:

A — throw from every method (this PR today). Uniform, no call-site changes, and safe where a plausible-looking value is dangerous: getdelSaved returning null on a network error causes canvasservice.js:1257-1258 to treat it as success and delete the document's change history. Against: wrong for methods whose callers were written for values, and it produces the commandSfcCallback path where a row is left at SaveVersion/UpdateVersion with createSaveTimer unable to re-arm.

B — return decided values (#44, for its ten). Matches existing call-site expectations; denial is safe for the lock methods and a delayed save costs little. Against: for readers there is often no safe value — getLocks returning {} is indistinguishable from "no locks", and sendAuthInfo (DocsCoServer.js:3402-3405) then tells every client the document is unlocked.

C — per-method. Most work, and the answer space is three-valued: deny (lock methods); throw or return a distinct sentinel (getdelSaved, getLocks); return a specific enum member (_checkAndUnlock must return Locked, since callers branch on Locked !== res and undefined takes the wrong branch); return an explicit unknown (presence, which #44 marks so callers can distinguish it from empty).

#44's existing policy covers the ten methods it implements, so that pattern is safe for those during the rebase. The methods beyond them are where this bites.

Views welcome, including on whether per-method is worth it for a first enablement or whether uniform throw is an acceptable start with per-method to follow.

@moodyjmz

Copy link
Copy Markdown
Member

@j-base64 just to add you here

@moodyjmz

Copy link
Copy Markdown
Member

CI results on acc43970 — the first run this PR has had.

Job Result
E2E (overlay on nightly), including the pkg binary build green
Redis editor data tests — standalone green
Redis Cluster editor data tests red, in setup

The cluster failure is the harness, not the code. The nodes start correctly via docker run redis:7-alpine, but the readiness loop and redis-cli --cluster create are invoked on the runner host, and ubuntu-latest does not ship redis-cli:

line 13: redis-cli: command not found

Add sudo apt-get install -y redis-tools before that step, or route the calls through docker exec redis-7000 redis-cli …. The suite itself passes on a six-node cluster once redis-cli is present — that is where the 41/41 figure above came from.

This job had never executed before today, so it is not a regression; the fork gate meant nothing had surfaced the missing tool.

On the pkg question: the Build server binaries step is green against the restructured six-file module, which supports the point that the files under editorDataRedis/ do not need listing. It builds with the default config, so it still does not boot with editorDataStorage: "editorDataRedis" — that demonstration remains outstanding.

Amendment to my first comment: I described this cluster job as better than anything on our side. The design still is, and the finding does not change that — it had just never run.

DCO remains the only other red, and git rebase --signoff covers it in the pass you are already making.

@moodyjmz

Copy link
Copy Markdown
Member

Merged main in to keep this current — one file, AdminPanel/server/sources/routes/fonts/router.js, nothing near the redis work. If it is in the way of your rebase, force-push straight over it; the merge commit is expendable.

Re-approved the checks against the new head.

@nvanlaerebeke
nvanlaerebeke force-pushed the feature/redis-editor-data-storage branch 2 times, most recently from 913378a to ee22902 Compare September 23, 2026 17:51
@nvanlaerebeke

nvanlaerebeke commented Sep 23, 2026 •

Copy link
Copy Markdown
Author

I suggest using the following configuration structure to support:

  • Single Redis
  • Redis Cluster
  • Redis Sentinel

The structure follows the current redis package API and the normalization logic in server/DocService/sources/editorDataRedis/base.js.

{
  "services": {
    "CoAuthoring": {
      "redis": {
        "name": "redis",
        "prefix": "ds:",

        "host": "redis.example.com",
        "port": 6379,

        "options": {
          "username": "redis-user",
          "password": "redis-password",
          "database": 0,
          "socket": {
            "tls": true,
            "rejectUnauthorized": true
          },
          "commandOptions": {
            "timeout": 30000
          }
        },

        "optionsCluster": {
          "rootNodes": [
            {
              "url": "redis://redis-cluster-0.example.com:6379"
            },
            {
              "url": "redis://redis-cluster-1.example.com:6379"
            },
            {
              "url": "redis://redis-cluster-2.example.com:6379"
            }
          ],
          "defaults": {
            "username": "redis-user",
            "password": "redis-password",
            "socket": {
              "tls": true,
              "rejectUnauthorized": true
            }
          },
          "commandOptions": {
            "timeout": 30000
          }
        },

        "optionsSentinel": {
          "name": "mymaster",
          "sentinelRootNodes": [
            {
              "host": "sentinel-a.example.com",
              "port": 26379
            },
            {
              "host": "sentinel-b.example.com",
              "port": 26379
            }
          ],
          "database": 0,
          "nodeClientOptions": {
            "username": "redis-user",
            "password": "redis-password",
            "socket": {
              "tls": true,
              "rejectUnauthorized": true
            }
          },
          "sentinelClientOptions": {
            "username": "sentinel-user",
            "password": "sentinel-password",
            "socket": {
              "tls": true,
              "rejectUnauthorized": true
            }
          },
          "commandOptions": {
            "timeout": 30000
          }
        }
      }
    }
  }
}

The example shows all supported topology blocks for reference. In an actual deployment, only one topology should be configured; the other blocks should remain empty.

Deployment Configuration
Single Redis host, port, and options
Redis Cluster optionsCluster.rootNodes and optionsCluster.defaults
Redis Sentinel optionsSentinel.name, sentinelRootNodes, nodeClientOptions, and sentinelClientOptions

Topology selection

The current implementation selects the topology as follows:

  1. If optionsSentinel is non-empty, Sentinel mode is selected.
  2. Otherwise, if optionsCluster.rootNodes is non-empty, Cluster mode is selected.
  3. Otherwise, standalone Redis is selected.
  4. If both Cluster and Sentinel are configured, the implementation throws an error.

Configuration details

  • options contains standalone Redis connection settings.
  • optionsCluster.rootNodes contains the Redis Cluster discovery nodes.
  • optionsCluster.defaults contains settings inherited by discovered cluster nodes, such as credentials and TLS.
  • Redis Cluster does not support a non-zero logical database.
  • optionsSentinel.name is the Sentinel master/group name.
  • optionsSentinel.sentinelRootNodes contains the Sentinel hosts and ports.
  • nodeClientOptions contains credentials and TLS settings for Redis master/replica connections.
  • sentinelClientOptions contains credentials and TLS settings for Sentinel connections.
  • optionsSentinel.database selects the logical database for Redis master/replica connections.
  • commandOptions belongs at the topology level and controls command behavior such as timeouts.
  • prefix is used by the editor-data adapter when constructing Redis keys.
  • name identifies the Redis connector and must currently be set to "redis".

Legacy configuration

iooptions is a legacy configuration namespace from the previous ioredis-based implementation.

The older/intermediate fields options.sentinels and sentinelPassword are not used by the current implementation.
The current implementation expects the native optionsSentinel structure described above.

The default configuration is defined in server/Common/config/default.json.

The normalization logic is implemented in server/DocService/sources/editorDataRedis/base.js.

@moodyjmz

Copy link
Copy Markdown
Member

Correction to my comment above, on the mechanism rather than the conclusion.

I wrote that with force-save state per replica, "every replica compares against a record another replica armed". That is backwards. Each replica compares the client's document-wide change index against its own force-save record, and only saves handled by that replica ever advance it — so a participant's replica arms a record early, every later save goes through a different replica, and the local record never moves. getForceSave returns the local value; resetForceSaveAfterChanges on that same replica is what armed it.

The conclusion is unchanged: sharing the locks while leaving force-save per-replica is worse than sharing neither. The deployment that reported this had the mechanism right before we did.

Nothing in this PR is affected — the surface is already complete, which is the right answer under either reading.

@j-base64

Copy link
Copy Markdown

Really nice work @nvanlaerebeke 🙌, and thanks for pushing this forward. Having the full surface plus standalone/cluster/sentinel in one place looks like an interesting base to build on.

With that in mind, a few things that might be worth a look:

  • Failover double-grant. A lock granted on a primary can be granted again on a promoted replica, because the promotion can lag the write that took the lock. We reproduced this against a real primary/replica pair. WAIT doesn't close it: in a run where the key was in fact lost on promotion it still returned 1 (reported success), and on the shipped replica-less default it returns 0 in under a second, so treating 0 as "unsafe, deny" would deny every save on a stock single-node install. There's no safe threshold to act on. This applies to any Redis-backed backend here, feat: add Redis-backed editor data storage for high availability #46 included, and it's a different mechanism from the sentinel maxCommandRediscovers resend already discussed above. Might be worth recording explicitly as a known, out-of-scope-for-now, pre-production gate: a first enablement can ship without it, it just shouldn't be described as failover-safe.

  • A fail-open vs fail-closed data point, in this PR's favor. In commandSfcCallback, getdelSaved is read at canvasservice.js:1258 as null == savedVal || '1' === savedVal. That's a loose ==, so both null and undefined satisfy it, and a truthy requestRes routes into cleanDocumentOnExit(..., deleteChanges=true), which deletes the document's changes (sqlBase.deleteChanges there also isn't awaited). A backend that fails open by returning absence on a Redis error would hit this and drop recovery changes, whereas this PR's throw propagates and never reaches it. So the uniform-throw policy is actually the safe choice on this specific path; worth keeping in mind when weighing per-method return values for the reader methods.

  • The two-process differential gap, made concrete. Both @moodyjmz and @MonaAghili flagged the missing multi-process test; confirming it from the diff, it's absent at the code level. There's no child_process / fork / worker_threads / spawn anywhere in the PR, so the cross-replica tests (e.g. the presence "visible from another replica" case) run two store instances inside one Node process. That catches basic shared visibility, but not independent crash, gc/cron timers, or shard identity, which is where the remaining cross-replica edges live: for example the orphan window where a crash between the main write and the non-atomic index update can leave state that cleanDocumentOnExit's presence guard then holds onto. contract.tests.js also checks method name/arity parity only (.length equality), not behavior, so a cross-replica behavioral regression wouldn't be caught there either. The lock-id fix that just landed is the kind of edge careful reading catches one at a time; a two-process differential suite (two independent processes on one shared Redis, with the in-memory backend as the oracle) would cover that whole class in one place, and being client- and implementation-agnostic it would outlive whichever base ends up chosen, so it might be worth tracking as its own follow-up. Concrete shape: kill one replica mid-operation and assert the other's view of presence and locks.

Keen to see where this goes 😊

@nvanlaerebeke

nvanlaerebeke commented Sep 24, 2026 •

Copy link
Copy Markdown
Author

Thank you everyone for the positive feedback and for taking the time to review the implementation in such detail.
The reviews helped me understand a lot of concerns that I would have missed on my own as my own knowledge doesn't go that in depth.

I’m still learning this part of the codebase, so I’ll try to take this as far as I can with my current knowledge. I also appreciate any further guidance to get this merged.

I'm pushing euro office at my work place as a replacement for office for the web.

Regarding the Redis configuration I previously suggested, the DocumentServer entrypoints expect the iooptions/ "old" configuration structure.

What would be the preferred way to handle this?

  1. Should I open a corresponding pull request in the main DocumentServer repository to update the entrypoint shell scripts?
  2. Should I leave the entrypoint unchanged for now and continue with the current PR until the implementation is further along?
  3. Alternatively, should the server implementation temporarily support the existing entrypoint format for compatibility?

I'm not a fan of "3", redis is currently not part of the codebase at all so it's better to start without any "legacy" as there should be no deployments with that configuration as those won't start due to the missing storage provider.

@nvanlaerebeke

nvanlaerebeke commented Sep 24, 2026 •

Copy link
Copy Markdown
Author

I wanted to look into the failure behavior, for completion I've added a list of all the methods and the suggested solution.
A few are straight forward, I'll do those first and can take a look at the others one by one once those are done.

Status Method Proposed failure behavior
⏳ addPresence TBD — presence write and global index update can partially succeed.
⏳ updatePresence TBD — presence and expiry-index updates can partially succeed.
✅ removePresence Throw; deletion is retry-safe, but the caller must not hide failure.
✅ getPresence Return an explicit unavailable result or throw; never use [] for Redis failure.
✅ lockSave Fail closed: return false.
✅ unlockSave Fail closed: return c_oAscUnlockRes.Locked.
✅ lockAuth Fail closed: return false.
✅ unlockAuth Fail closed: return c_oAscUnlockRes.Locked.
✅ getDocumentPresenceExpired Throw/propagate failure; never treat Redis failure as an empty expiration list.
✅ _ackDocumentPresenceExpired Throw/propagate failure; do not acknowledge an expiration that was not confirmed.
⏳ removePresenceDocument TBD — the presence removal and index removal are separate operations.
✅ addLocks Throw; retry only with an explicit idempotency policy.
⏳ addLocksNX TBD — a lost response must not be retried blindly because conflict information may be lost.
✅ removeLocks Throw; retry is safe only when removal is lock-ID based and version-safe.
⏳ removeAllLocks TBD — retrying a lost response could remove newer locks.
✅ getLocks Return an explicit unavailable result or throw; never return {} on failure.
⏳ addMessage TBD — retrying after a lost response may duplicate the message.
⏳ removeMessages TBD — retrying after a lost response may remove newly added messages.
✅ getMessages Return an explicit unavailable result or throw; never return [] on failure.
⏳ setSaved TBD — retry requires protection against overwriting newer save state.
⏳ getdelSaved TBD — requires a claim/read/ack or equivalent durable protocol; null must not represent failure.
⏳ setForceSave TBD — requires idempotency/versioning and recovery if the timer update fails.
✅ getForceSave Return an explicit unavailable result or throw; never use null for Redis failure.
⏳ checkAndStartForceSave TBD — state may be changed even when the response is lost.
⏳ checkAndSetForceSave TBD — requires idempotent/version-aware retry behavior.
⏳ removeForceSave TBD — retrying after a lost response may remove newer force-save state.
⏳ cleanDocumentOnExit TBD — cleanup is multi-step and must be retry-safe across replicas.
✅ addForceSaveTimerNX Throw on failure; retry is safe because the operation is NX, and the timer must be re-armed.
✅ getForceSaveTimer Throw/propagate failure; never treat Redis failure as an empty timer list.
✅ _ackForceSaveTimer Throw/propagate failure; do not acknowledge a timer item unless removal is confirmed.

@nvanlaerebeke

Copy link
Copy Markdown
Author

Because there is a lot of information in the PR I generated a "current status" about what's done and still pending to get a better overview of the current state.

Current status

The Redis editor-data implementation has progressed significantly since the initial review. The recent work has focused primarily on standalone Redis and Redis Cluster support. Redis Sentinel remains an open workstream and has deliberately not been treated as complete yet.

Completed

  • Confirmed the Redis behavior against the existing editorDataMemory semantics:
    • re-entrant locks with TTL refresh;
    • correct lock-notification ownership behavior;
    • first-write-wins force-save timers;
    • numeric unlock results;
    • presence version-token handling.
  • Fixed lock removal to use the stable lock ID instead of comparing serialized lock values.
  • Made POP_EXPIRED bounded and retry-safe so large backlogs do not block Redis and lost replies do not permanently discard work.
  • Preserved force-save payloads during Lua state transitions.
  • Added an explicit RESP2 setting for editorDataRedis clients.
  • Added dedicated shard-counter expiry configuration and tests.
  • Added packaged-binary smoke coverage that starts DocService with editorDataStorage: "editorDataRedis".
  • Added phase-one Redis failure handling and regression tests.
  • Added a two-process differential test suite using an in-memory oracle.
  • Fixed the Redis Cluster CI setup so readiness checks and cluster creation use redis-cli inside the Redis container rather than requiring host-side tools.
  • Updated 3DPARTY.md for the Redis dependency change.
  • Documented the current Redis replication/failover lock limitation.
  • Added bounded Sentinel reconnect behavior and unit coverage for reconnect failure cases.
  • Added signed-off commits and the required assisted-by trailers to the current branch.

Sentinel status

Sentinel is not considered complete yet.

The current branch contains Sentinel option normalization and bounded reconnect behavior, but the remaining integration work includes:

  • aligning the server configuration with the DocumentServer entrypoint configuration;
  • validating the final optionsSentinel/iooptions decision;
  • adding a real Sentinel topology/failover test;
  • verifying behavior during master promotion and reconnect;
  • confirming that a Sentinel-configured deployment cannot silently fall back to standalone Redis.

This is the main topology-related item still open. The corresponding DocumentServer entrypoint work is being handled separately.

Remaining Redis work

The main remaining work inside this PR is:

  1. Complete the Redis failure-policy review at the call sites.

    Phase-one behavior is implemented, but the remaining methods need an explicit decision about fail-open versus fail-closed behavior. The most important paths are save-task recovery, getdelSaved, lock/read methods, presence state, and multi-step cleanup. The behavior must not turn a Redis failure into a plausible empty result that causes destructive cleanup or incorrectly reports a document as unlocked.

  2. Decide whether additional retry/offline-queue restrictions are required.

    The current implementation needs a final decision about queued commands and retry behavior, especially for non-idempotent operations such as message insertion and connection counters.

  3. Review the global index design.

    The document and force-save indexes currently use single global keys. Sharding those indexes would reduce the single-slot failure domain in Redis Cluster, but this is a larger data-model change and should likely be handled separately if it is not required for the first merge.

  4. Review atomic presence/index updates.

    Presence data and its global index are updated in separate operations. A crash between those operations can leave stale or orphaned index state. This is a follow-up hardening item rather than part of the completed basic Redis implementation.

  5. Keep the failover-lock limitation explicit.

    Redis Sentinel/replication failover can lose an acknowledged lock write and permit a second lock grant after promotion. Making this fully failover-safe requires a stronger coordination mechanism, such as fencing tokens or a consensus-backed lock authority. The current implementation documents this limitation rather than claiming strict failover safety.

Intentionally not required for this PR

  • Manually listing every editorDataRedis source file in the package configuration.
  • Removing the intentional deleteKey environment-gated hook.
  • Making cleanDocumentOnExit unconditional, since the presence guard protects other live replicas.
  • Removing withTimeout, because node-redis’ native timeout does not cover commands already written to the socket.
  • Carrying both node-redis and ioredis.

The Redis and Redis Cluster implementation is substantially further along. Sentinel integration and the remaining failure-policy decisions are the main items still requiring focused work.

@MonaAghili

Copy link
Copy Markdown

Hi @nvanlaerebeke thanks for your contribution, and great work! I've done another review

Reviewed at HEAD: ec7900f516271e4e42a1fb029f09a05d06d747fc

1. Executive summary

The implementation is in materially better shape than the previous review round found. Every
previously-reported defect was re-tested against the current code and all of them are genuinely
fixed
— stable lock-ID removal, the cjson force-save payload corruption, the POP_EXPIRED
claim/lease/ACK protocol, explicit RESP2, and the Sentinel command-replay bound. The last three were
verified against the actual node-redis 6.2.1 source rather than taken on the word of the commit
messages.

The mechanism that fixes #267 is sound. pkg . runs from inside DocService and reads that
package's pkg.scripts, which already listed ./sources/editorDataRedis.js; the root cause of #267
was that the listed file did not exist, so pkg silently skipped it. That file now exists and
statically requires the six-file module, so pkg will trace the whole graph.

Two things block merge. Neither is a code-quality problem:

  1. The evidence does not exist. CI last executed 13 commits ago at 5e3043c1, where the Cluster
    job failed. Everything since — including the packaged smoke test that is the only proof that
    #267 is fixed — has never run.
  2. Sentinel is silently misconfigured in orchestrated DocumentServer deployments, because the
    config key this PR introduces is not the key the deployment entrypoint writes.

Beyond those, the most substantive remaining concerns are a non-retry-safe getdelSaved, an
undocumented behavioural divergence in cleanDocumentOnExit, and a complete absence of executable
Sentinel coverage.


2. What the requirement actually is

Issue #267 is a packaging defect, not a feature request:

Setting services.CoAuthoring.server.editorDataStorage to "editorDataRedis" in local.json
causes the DocService to crash on startup with a MODULE_NOT_FOUND error. The module
editorDataRedis.js is not included in the native .deb package (compiled binary via pkg).

Error: Cannot find module './../../DocService/sources/editorDataRedis'
Require stack:
- /snapshot/server/Common/sources/notificationService.js
- /snapshot/server/DocService/sources/utilsDocService.js
- /snapshot/server/DocService/sources/DocsCoServer.js
- /snapshot/server/DocService/sources/server.js
code: 'MODULE_NOT_FOUND',
pkg: true

The acceptance bar is therefore:

  • A packaged/native DocService must start successfully with editorDataStorage: "editorDataRedis".
  • It must work from the minimal config the issue shows: redis: { host, port } in local.json.
  • "The source module exists" is not sufficient.

Everything else in this review — failure semantics, distributed correctness, GC, Sentinel — is
assessed as production readiness for the backend the fix enables, not as part of #267 itself.

Why the module was missing, and why it is now found

DocService/package.json pkg.scripts has listed ./sources/editorDataRedis.js all along; this PR
does not change the pkg block at all:

"pkg": {
  "scripts": [
    "./sources/editorDataMemory.js",
    "./sources/editorDataRedis.js",   // listed before this PR — the file just did not exist
    "./sources/pubsubRabbitMQ.js",
    ...
  ]
}

The module is loaded dynamically from a config string, so pkg's static analysis cannot reach it from
the entry point:

File Line Expression
DocsCoServer.js 100 require('./' + cfgEditorDataStorage)
DocsCoServer.js 101 require('./' + (cfgEditorStatStorage || cfgEditorDataStorage))
Common/sources/notificationService.js 36 require('./../../DocService/sources/' + (...))
DocService/sources/changes2forgotten.js 42 require('./' + (...))
DocService/sources/routes/info.js 21 require(`../${cfgEditorStatStorage}`)

pkg.scripts is exactly the mechanism for this case. The new shim satisfies it with a static literal:

// DocService/sources/editorDataRedis.js
'use strict';
// Compatibility entry point for the pkg configuration and existing consumers.
module.exports = require('./editorDataRedis/index.js');

and the chain from there is all static literals:
index.js → './core' → './editorData' + './editorStat' → './base' → './scripts'.

Both build paths use the same manifest-driven invocation, so the fix covers the .deb and the
Docker image equally:

  • server/.github/workflows/e2e.yml:98 — ( cd DocService && pkg . -t linux-x64 ... )
  • DocumentServer/develop/setup/Makefile:186 — pkg . -t linux-x64 ...

Conclusion: the packaging fix is correct by construction. It has not been demonstrated by an
executed build — see Blocker 1.


3. Method

  1. Fetched refs/pull/46/head and extracted the tree to a scratch directory, so the review ran
    against the real PR HEAD rather than the stale local branch (735b1dea, 4 commits behind).
  2. Read the complete diff against the correct merge-base.
  3. Read the full current implementation: base.js (770), scripts.js (384), editorData.js (353),
    editorStat.js (236), core.js, index.js, the shim, and the gc.js delta.
  4. Read editorDataMemory.js as the behavioural contract and traced every caller of EditorData /
    EditorStat in DocsCoServer.js, canvasservice.js, gc.js, routes/info.js,
    notificationService.js, changes2forgotten.js, shutdown.js.
  5. Queried live CI state via gh run list / gh api — this is how the stale-CI blocker was found.
  6. Downloaded and read node-redis 6.2.1 and @redis/client 6.2.1 to verify RESP propagation,
    Sentinel option names, and maxCommandRediscovers semantics, rather than trusting commit messages.
  7. Read the DocumentServer deployment entrypoints to check config compatibility.

The specification for this review is issue #267.


4. Blockers

Blocker 1 — The packaged smoke test has never executed

Location: .github/workflows/e2e.yml:125-156, .github/workflows/redisEditorDataTests.yml

Requirement violated: #267 is a packaging defect; only a packaged binary starting with
editorDataRedis demonstrates the fix.

Evidence (from gh run list --branch feature/redis-editor-data-storage):

Commit Date Workflow Result
5e3043c1 2026-09-23 18:13 Redis editor data tests failure
5e3043c1 2026-09-23 18:13 E2E (overlay on nightly) success — but pkg-e2e.sh did not exist yet
ee229022 … ec7900f5 — both action_required (never executed)

Job breakdown for the failing run (35901026235):

Redis Cluster editor data tests: failure
Redis editor data tests:         success

The Cluster job failed on infrastructure, not on product code:

/home/runner/work/_temp/....sh: line 13: redis-cli: command not found
##[error]Process completed with exit code 1.

13 commits have landed since that run, none of them tested:

75783478 ci(redis): run cluster setup with container redis-cli     <- plausibly fixes the above
ab67fbba fix(redis): make expired-entry claims retry-safe
da0744e8 fix(redis): preserve force-save payloads during state updates
8a28eead fix(ci): use noninteractive Redis cluster confirmation
ee6371ef test(redis): add packaged editorDataRedis smoke test      <- the #267 verification
4096a85a style(server): fix Prettier formatting
65d2bb95 fix(editorDataRedis): implement phase one failure policy
70d04075 docs(server): update Redis dependency documentation
49807108 docs(server): document Redis failover lock limitation
80ce0d29 test(redis): add differential oracle for process-based tests
7e23c91c fix(redis): explicitly use RESP2 for editorData storage
2dad59b9 fix(redis): add dedicated shard counter expiry
ec7900f5 fix(redis): bound Sentinel reconnects for editorData storage

tests/redis/editorDataRedis.pkg-e2e.sh and its e2e.yml wiring arrived in ee6371ef, after the
last executed run. Confirmed by git ls-tree 5e3043c1 — only editorDataRedis.pkg-smoke.js (a Jest
helper) existed there, not the shell driver.

Impact: The PR's central claim is unevidenced. The known-failing Cluster job is plausibly fixed
by 75783478/8a28eead, which replace bare redis-cli with docker exec redis-7000 redis-cli — but
that is reasoning, not a green run. The entire failure policy, the RESP2 switch, the GC lease
redesign, the differential oracle, the shard-counter TTL and the Sentinel reconnect bounding are all
untested in CI.

Why current tests don't catch it: They have not run.

Suggested fix: Approve and run both workflows on ec7900f5. Do not merge on a reasoned-fix basis
for a packaging bug whose entire point is that reasoning about the build was wrong the first time.

Confidence: High — directly verified against the GitHub Actions API.


Blocker 2 — Sentinel config key mismatch silently disables Sentinel

Location: DocService/sources/editorDataRedis/base.js:83,216-219 vs
Common/config/default.json:410-413 vs
DocumentServer/build/scripts/orchestrated/docker-entrypoint.sh:132-139

Invariant: A deployment that configures Sentinel must get Sentinel, or must fail loudly.

What the PR changed in Common/config/default.json:

         "options": {},
         "optionsCluster": {},
-        "iooptions": {
-          "lazyConnect": true
-        },
-        "iooptionsClusterNodes": [],
-        "iooptionsClusterOptions": {
-          "lazyConnect": true
-        }
+        "optionsSentinel": {}

What the module reads:

// base.js:83
const cfgRedisOptionsSentinel = cfgRedis.get('optionsSentinel');

// base.js:216-219
function hasNodeSentinel() {
  const options = cloneConfig(cfgRedisOptionsSentinel) || {};
  return Object.keys(options).length > 0;
}

What the orchestrated DocumentServer entrypoint actually writes:

"redis": {
  "name":  "'${REDIS_CONNECTOR_NAME:-redis}'",
  "host":  "'${REDIS_SERVER_HOST:-localhost}'",
  "port":   '${REDIS_SERVER_PORT:-6379}',
  "options": { "user": "...", "password": "...", "db": "..." },
  "optionsCluster": { '${REDIS_CLUSTER}' },
  "iooptions": {                                 // <-- Sentinel lives HERE
    "sentinels": '${REDIS_SENTINEL}'
    "name": "'${REDIS_SENTINEL_GROUP_NAME:-mymaster}'",
    "sentinelPassword": "'${REDIS_SENTINEL_PWD}'",
    "username": "...", "password": "...", "db": "..."
  }
}

Failure scenario:

  1. Operator sets REDIS_SENTINEL_NODES=host1:26379 host2:26379.
  2. The entrypoint writes that topology into iooptions. It never writes optionsSentinel.
  3. optionsSentinel falls back to {} from default.json, so hasNodeSentinel() returns false.
  4. _createClient takes the standalone branch and calls
    redis.createClient(normalizeNodeOptions(cfgRedisOptions, this.database)), connecting directly to
    REDIS_SERVER_HOST:REDIS_SERVER_PORT.
  5. No error. No warning. The log line even reads created redis client (cluster=false, host=…).

Impact: A Sentinel-configured deployment gets a single-node connection with no master discovery
and no failover. For a PR titled "add Redis-backed editor data storage for high availability",
this is precisely the failure mode that matters. If REDIS_SERVER_HOST happens to point at a
Sentinel process (port 26379) rather than a data node, ordinary data commands will fail outright; if
it points at the current master, it works until the first failover and then breaks silently.

optionsCluster is not affected — the entrypoint's {rootNodes, defaults} shape matches
normalizeClusterOptions exactly, including defaults: {username, password}.

Why current tests don't catch it: tests/redis/editorDataRedis.pkg-e2e.sh:126 explicitly pins
the config it tests with:

'...,"redis":{"host":"127.0.0.1","port":%s,"prefix":"pkg-smoke:%s:","optionsCluster":{},"optionsSentinel":{}}...'

so the smoke test never sees entrypoint-generated config. The Sentinel unit tests in
connection.tests.js assert the output shape of normalizeSentinelOptions; none of them exercise
key discovery from real deployment config.

Answering the self-consistency question directly: the companion DocumentServer change is
required
, it does not exist, and the current state silently misconfigures Sentinel
deployments.

Suggested fix (pick one):

  • Accept iooptions as a Sentinel source and map sentinels → sentinelRootNodes,
    sentinelPassword → sentinelClientOptions.password; or
  • Land the companion docker-entrypoint.sh change writing optionsSentinel, and state the required
    DocumentServer version coupling in Readme.md; or
  • If Sentinel is out of scope for this release, delete the Sentinel branch rather than shipping a
    path that cannot be reached by any supported deployment.

Confidence: High — read directly from the entrypoint source and the module's config reads.


5. Major issues

Major 1 — getdelSaved is not retry-safe and conflates "Redis failed" with "nothing to recover"

Location: DocService/sources/editorDataRedis/editorData.js:250-252 →
DocService/sources/canvasservice.js:1257-1258

// editorData.js
EditorData.prototype.getdelSaved = async function (ctx, docId) {
  return this._eval(GETDEL_SCRIPT, [this._docKeys(ctx, docId).saved], []);
};
-- scripts.js GETDEL_SCRIPT
local value = redis.call('GET', KEYS[1])
if value then redis.call('DEL', KEYS[1]) end
return value
// canvasservice.js:1257
const savedVal = yield docsCoServer.editorData.getdelSaved(ctx, docId);
requestRes = null == savedVal || '1' === savedVal;

The value's origin — DocsCoServer.js:4520-4529, the saved Command Service endpoint:

case 'saved': {
  // Result from document manager about file save processing status after assembly
  if ('1' !== params.status) {
    // "saved" request is done synchronously so populate a variable to check it after sendServerRequest
    yield editorData.setSaved(ctx, docId, params.status);
    ctx.logger.warn('saved corrupted id = %s status = %s conv = %s', docId, params.status, params.conv);
  }
  break;
}

So the safety property is: if the integrator reported a failed save, do not delete the document's
changes — store it as forgotten instead.

Failure scenario:

T0  integrator POSTs saved status='2'  -> SET ds:{editor:T:D}:saved = '2'
T1  commandSfcCallback -> getdelSaved -> EVAL GETDEL_SCRIPT
T2  Redis executes: GET returns '2', DEL removes the key
T3  reply lost / 30s command timeout
T4  _withCommandTimeout throws ETIMEDOUT -> _abortClient() nulls the client
T5  exception propagates out of commandSfcCallback
T6  any later read of that key returns null
T7  requestRes = (null == null) -> TRUE -> "success"

The GETDEL is atomic, but the application operation is not. Nothing in the return value
distinguishes null-because-absent from null-because-the-reply-was-lost.

Scoped honestly — what I could and could not establish: I could not demonstrate an automatic
data-deletion path, and I want to be explicit about that rather than overstate it:

  • receiveTask catches, logs, and ack()s in a finally (canvasservice.js:2088-2093), so the
    queue does not redeliver on an exception.
  • The needRetry backoff is only set inside the sendServerRequest catch, which is upstream of
    getdelSaved; a getdelSaved failure does not set it.
  • A full sfc retry re-runs sendServerRequest, which causes the integrator to re-report and
    re-populate via setSaved.

The realistic outcome is therefore an aborted callback leaving the document half-processed —
storeForgotten never runs, cleanDocumentOnExitPromise never runs, but the changes are not deleted
either. That is the benign end of the range. The unsafe-retry property is nonetheless real and is a
regression versus editorDataMemory, where a local object read cannot lose a reply.

Why current tests don't catch it: failure.tests.js monkey-patches _eval to throw before
mutation:

data._eval = async () => { throw new Error('Redis unavailable'); };

That tests the "failed before mutation" branch only. The holdAfterCommand harness in
process.worker.js:66-71 can simulate commit-then-lost-reply and is only applied to addPresence.

Suggested fix: Return a distinct sentinel (or throw a typed RedisUnavailableError) for the
unknown case so the caller can choose storeForgotten over success; or hold the value until an
explicit ack, as the GC path already does.

Confidence: High on mechanism, Medium on production impact.


Major 2 — cleanDocumentOnExit becomes a silent no-op when presence remains

Location: scripts.js:340-354 (CLEAN_DOCUMENT_SCRIPT), editorData.js:327-339

local expired = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
if #expired > 0 then
  redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
  for _, userId in ipairs(expired) do redis.call('HDEL', KEYS[2], userId) end
end
if redis.call('HLEN', KEYS[2]) > 0 then
  return {0, ''}                     -- <-- deletes NOTHING
end
local version = redis.call('GET', KEYS[3]) or ''
redis.call('DEL', unpack(KEYS))
return {1, version}

The memory backend deletes unconditionally (editorDataMemory.js:234-243):

EditorData.prototype.cleanDocumentOnExit = async function (ctx, docId) {
  const tenantData = this.data[ctx.tenant];
  if (tenantData) { delete tenantData[docId]; }
  const tenantTimer = this.forceSaveTimer[ctx.tenant];
  if (tenantTimer) { delete tenantTimer[docId]; }
};

When any live presence user remains, the Redis version skips locks, messages, saved, forcesave,
savelock, lockdocument
, and — because editorData.js:334 gates on result[0] === 1 — also skips
both the documentsKey index removal and the forceSaveTimerKey ZREM.

Callers affected:

Caller Context
DocsCoServer.js:1529 cleanDocumentOnExit after a successful save
canvasservice.js:1317 the storeForgotten path, whose inline comment still reads "clean redis (redisKeyPresenceSet and redisKeyPresenceHash removed with last element)"

Assessment: the conditional is genuinely protective — it is what makes the GC claim race safe.
POP_EXPIRED removes a member from the source index at claim time, so a document that gets
re-activated between claim and processing would otherwise be wiped while users are editing it. The
guard prevents that, and presence.tests.js covers it
('document cleanup does not delete presence while a user remains').

The problem is that it is an unannounced contract change: nothing in Readme.md or the code
comments records it, the stale comment at canvasservice.js:1316 now describes behaviour that no
longer happens, and no differential test pins the intended difference against editorDataMemory.

Suggested fix: Document the deviation, update the stale comment, and add a differential case
asserting the intended difference rather than leaving it implicit.

Confidence: High.


Major 3 — Sentinel has no executable coverage of any kind

redisEditorDataTests.yml defines exactly two jobs:

  • redis-editor-data-tests — redis:7-alpine service container (standalone)
  • redis-editor-data-cluster-tests — six-node cluster via docker run

There is no Sentinel job. The six Sentinel tests in connection.tests.js assert the shape of
normalizeSentinelOptions output and the reconnect-strategy curve:

- normalizes native Sentinel options and applies the selected database to node clients
- uses bounded deterministic backoff for Sentinel node reconnects
- rejects incomplete native Sentinel options
- uses the native Sentinel raw-command signature
- uses the native Sentinel batch-command signature
- rejects an initial Sentinel connection failure and clears the failed client

None starts a Sentinel. createSentinel, master discovery, Sentinel authentication, TLS, failover,
reconnect-during-failover and the sendCommand(false, …) signature against a real server are all
unexercised — and ec7900f5, the newest commit in the PR, changes exactly this surface
(disableOfflineQueue, commandsQueueMaxLength, reconnectStrategy, maxCommandRediscovers).

Combined with Blocker 2, the
Sentinel path is both unreachable from real deployment config and untested.

Confidence: High.


Major 4 — EditorStat.deleteKey now really deletes a caller-supplied, unprefixed key

Location: editorStat.js:232-234

EditorStat.prototype.deleteKey = async function (key) {
  await this._command(['DEL', key]);
};

versus the memory backend's deliberate no-op (editorDataMemory.js:499-501):

EditorStat.prototype.deleteKey = async function (_key) {
  //no need
};

Reachability: only when process.env.REDIS_SERVER_DB_KEYS_NUM is set, which constructs the proxy
on a separate logical database (DocsCoServer.js:156-158):

let editorStatProxy = null;
if (process.env.REDIS_SERVER_DB_KEYS_NUM) {
  editorStatProxy = new editorStatStorage.EditorStat(process.env.REDIS_SERVER_DB_KEYS_NUM);
}

All five call sites guard on the method's existence, so there is no TypeError:

Call site Argument
DocsCoServer.js:1531 docId
DocsCoServer.js:2191 docId
canvasservice.js:480 docId
canvasservice.js:495 mask.key
canvasservice.js:1319 docId

Concern: the key is used verbatim and unprefixed — DEL <docId> in whatever logical DB the
env var names. That key format is an upstream-owned convention this repository does not define.
Implementing the interface is defensible; guessing the key layout and issuing DEL against a
possibly shared instance is the risky part, and it is the one operation in the module that can
destroy data belonging to another component.

Note also that with Cluster configured, constructing this proxy with a non-zero database throws at
first use (base.js:426-429), which is correct but deferred to command time rather than construction
time.

Suggested fix: Either keep the no-op (matching the memory backend and the fact that the proxy
scaffolding is inert upstream machinery), or document the key contract and namespace it.

Confidence: High on behaviour; reachability gated by an env var nothing in this repo sets.


6. Verified fixes

Every item below was a finding or topic in the earlier review round and was re-tested against
ec7900f5
. All are genuinely fixed. Do not re-report these.

6.1 Stable lock-ID removal — FIXED

-- REMOVE_LOCKS_SCRIPT
for i = 1, #ARGV, 2 do
  redis.call('HDEL', KEYS[1], ARGV[i])
end
return redis.call('HLEN', KEYS[1])

argsFromObject(locks) produces [id1, json1, id2, json2, …]; the stride-2 loop consumes only the
odd indices, i.e. the lock IDs, and ignores the JSON payloads entirely. Deletion is by hash field
name — the stable ID — not by value.

Checks demanded by the review brief:

Question Answer
Are lock IDs unique enough? They are the caller's own hash keys, generated upstream by the SDK; the backend does not invent them.
Does the ID survive _recalcLockArray? Yes. DocsCoServer.js:2391-2425 builds res = {} and assigns under the same lockId; it mutates lock.block.rangeOrObjectId.* only.
Can removal delete another lock? No — HDEL by exact field name.
Can a stale owner remove a newer lock? removeUserLocks (DocsCoServer.js:2249-2262) re-reads getLocks immediately before building toRemove, filtered by lock.user === userId.
Are Redis and memory semantics equivalent? Yes — memory does delete data.locks[lockId] over the same key set.
Concurrent mutation The whole loop is one Lua script, hence atomic.

One genuinely important detail the fix gets right: in the memory backend _recalcLockArray mutates
the shared data.locks objects in place, so no write-back is needed. With Redis, getLocks
returns freshly decoded objects, so the write-back at DocsCoServer.js:3602
(yield editorData.addLocks(ctx, docId, docLockMod)) is load-bearing. It is present.

6.2 Force-save payload corruption through cjson — FIXED

The payload is never decoded by Lua. STORE_FORCE_SAVE_SCRIPT writes pre-serialized JSON into
discrete hash fields, and START/SET only touch scalars:

redis.call('HSET', KEYS[1],
  'time', ARGV[1], 'index', ARGV[2],
  'baseUrl', ARGV[3], 'baseUrlDefined', ARGV[4],
  'changeInfo', ARGV[5], 'changeInfoDefined', ARGV[6],
  'convertInfo', ARGV[7], 'convertInfoDefined', ARGV[8],
  'started', '0', 'ended', '0')

Verified against the brief's checklist:

  • Payload opaque — yes, no cjson.decode/encode anywhere in scripts.js.
  • Only intended scalars change — START sets started/ended; SET sets
    started/ended/convertInfo/convertInfoDefined.
  • changeInfo byte-identical — never rewritten after setForceSave.
  • convertInfo preserved — with an explicit convertInfoDefined flag distinguishing null
    from omitted (editorData.js:254-256).
  • Arrays stay arrays / large numbers unchanged — because JSON round-trips in Node, never Lua.
  • State transitions equivalent — START requires started ~= '1', matching
    if (data.forceSave && !data.forceSave.started).

A bonus improvement over the prior design: time/index are compared as jsonEncoded strings, so
123 and '123' no longer conflate — this is now stricter than before and matches the memory
backend's ===.

Concurrency, as the brief asked:

Sequence Outcome
A and B both checkAndStartForceSave Exactly one wins; the loser gets nil. Asserted in process.tests.js:444-449 across two real processes.
A and B both checkAndSetForceSave Guarded by the time/index CAS; a stale caller gets nil. Asserted at process.tests.js:450-460.
A removeForceSave races B setForceSave DEL vs HSET — last writer wins. A stale removeForceSave can drop a newer record; this is the same race the memory backend has, and no caller interleaves them.

6.3 POP_EXPIRED lost-response behaviour and batching — FIXED

The design is a proper claim / lease / acknowledge protocol with per-claim tokens:

-- POP_EXPIRED: claim() moves a member into the lease ZSET and records the claim token
local function claim(member)
  redis.call('ZADD', KEYS[2], leaseUntil, member)
  redis.call('HSET', KEYS[3], member, claimId)
  table.insert(values, member)
end
-- ACK_EXPIRED: only the current claimant may release
if redis.call('HGET', KEYS[2], ARGV[2]) ~= ARGV[1] then
  return 0
end
redis.call('HDEL', KEYS[2], ARGV[2])
return redis.call('ZREM', KEYS[1], ARGV[2])

Key-order check: _ackExpired(leaseKey, claimsKey, item) passes [leaseKey, claimsKey], so
ZREM KEYS[1] targets the lease ZSET, not the source index. A late ACK therefore cannot delete a
freshly re-added source entry. Claim tokens are monotonic per instance
(${randomUUID()}:${++sequence}, editorData.js:136-139).

Against the brief's exact adversarial scenarios:

Scenario Behaviour
Worker A claims, crashes, lease expires, Worker B claims, A sends delayed ACK A's ACK carries the old claimId; HGET mismatch → return 0. B's claim survives.
Worker A claims 100, processes 40, crashes 40 acked and released; the other 60 stay in the lease ZSET and are reclaimed after POP_EXPIRED_LEASE_MS (5 min).
A newer expiry written while leased Reclaim checks ZSCORE KEYS[1] member; if present, it drops the stale claim (HDEL) and lets the newer source entry win.
Duplicate processing At-least-once by design; gc.js operations are idempotent.
Multiple GC workers Claim and index-removal happen in the same script, so no two workers can claim the same source entry.
Cluster hash slots All three keys share the {editor:index} tag.

gc.js consumes it correctly — the ACK helper degrades safely for the memory backend:

function acknowledgeExpired(editorData, method, item) {
  const acknowledge = editorData[method];
  return typeof acknowledge === 'function' ? acknowledge.call(editorData, item) : undefined;
}

One subtlety worth recording as correct: _popExpired stores claim IDs in a module-level
WeakMap keyed on the decoded item array, and gc.js:143 does expiredKeys.sort(...). Sorting
reorders the array but preserves element identity, so the WeakMap lookup still resolves. Both
gc.js call sites use docsCoServer.editorData, the same instance that issued the claim.

Coverage is real — ten tests in expiration.tests.js, including
'does not let a stale document-presence acknowledgement remove a retry',
'recovers only the unacknowledged document-presence item after a partial batch', and
'recovers document-presence expiration after a client timeout'.

6.4 Packaged editorDataRedis smoke test — EXISTS AND IS CORRECTLY DESIGNED (but unrun)

tests/redis/editorDataRedis.pkg-e2e.sh genuinely satisfies the brief's seven-point bar:

Requirement Evidence
Builds the pkg binary e2e.yml:92-100 — pkg . -t linux-x64 per service
Uses the packaged binary pkg-e2e.sh:11 locates */server/DocService/docservice inside the overlay image; the source tree is absent
Enables editorDataRedis pkg-e2e.sh:126 sets both editorDataStorage and editorStatStorage
Same runtime path as production Runs the real binary under NODE_ENV=production-linux, NODE_CONFIG_DIR=/etc/euro-office/documentserver
Starts DocService pkg-e2e.sh:130-134, after supervisorctl stop docservice to isolate the port
Verifies health /healthcheck must return true, then three more probes over ~6 s
Proves the backend loaded grep -q '\[editorDataRedis\]' on the log, plus an explicit MODULE_NOT_FOUND guard

The [editorDataRedis] grep matches REDIS_LOG_PREFIX (base.js:62) and is reachable because the
test forces log.options.categories.default.level = DEBUG, and _createClient logs at debug.

This is a good test. It has never executed — see Blocker 1.
Note also that it pins optionsCluster/optionsSentinel to {}, so by construction it cannot catch
Blocker 2.

6.5 Process-based differential testing — FIXED, and genuinely multi-process

editorDataRedis.process.worker.js is a real child_process.fork target, not an in-process fake:

Property Evidence
Independent processes process.send / process.on('message'); distinct process.pid reported
Independent connection state Each worker constructs its own EditorData + EditorStat at module load (worker.js:9-10)
Independent shard IDs EditorStat.sampleId = ${process.pid}:${randomBytes(12)} (editorStat.js:27)
Independent timers Separate event loops
Actual process crash harness.kill('replica-a', 'SIGKILL') (process.tests.js:521)
State after one process disappears runCrashScenario polls from replica-b and asserts the write survived
Cross-process visibility runCrossProcessRedisScenarios writes on A, reads on B throughout
Concurrent operations Promise.all([...checkAndStartForceSave on A and B]), asserting exactly one winner

The lost-response injection is real — holdAfterCommand lets the command commit, emits
redis-command-committed, then returns a never-settling promise:

if (message.holdAfterCommand) {
  return Promise.resolve(result).then(() => {
    send({type: 'redis-command-committed', requestId: message.requestId});
    return new Promise(() => {});
  });
}

What the oracle still does not cover (answering the brief's question directly): runTrace covers
save/auth locks, document locks, addLocksNX, removeLocks, messages, setSaved/getdelSaved, the
force-save lifecycle, the force-save timer, unique users, monthly users and lockNotification. It
does not cover cleanDocumentOnExit, removeAllLocks, removeMessages, removeForceSave, the
shard connection counters, setEditorConnections, shutdown tracking, licence storage or deleteKey.
Presence is necessarily Redis-only (the memory backend is degenerate there) and is covered separately
by runCrossProcessRedisScenarios and presence.tests.js. The whole suite is describe.skipped
under Cluster.

6.6 RESP2 — FIXED, verified against node-redis source

base.js sets RESP: 2 at every client level. I verified propagation in @redis/client@6.2.1
rather than assuming it:

  • Cluster — dist/lib/cluster/cluster-slots.js:550: #createClient passes
    RESP: this.#options.RESP to every node client, and line 465 does the same for the discovery
    client. Setting RESP at the top level in normalizeClusterOptions and stripping it from
    defaults is therefore correct, not a bug.
  • Sentinel — dist/lib/sentinel/index.js:600-601, with the source comment
    "first take the globally set RESP", then RESP: this.#RESP.

This matters because ADD_LOCKS_NX_SCRIPT returns {conflict, HGETALL} and decodeHash expects
flat RESP2 arrays.

6.7 Sentinel command replay bound — FIXED

REDIS_SENTINEL_MAX_COMMAND_REDISCOVERS = 0. In sentinel/index.js:

this.#maxCommandRediscovers = options.maxCommandRediscovers ?? 16;   // line 561
...
if (++count > this.#maxCommandRediscovers) { ... }                   // line 720

With 0, the first failure makes ++count === 1 > 0 true and the client gives up immediately rather
than replaying a command that may already have committed. The default would have been 16 replays.
The accompanying code comment states the intent accurately.

6.8 node-redis 6 adoption — FIXED, option names verified

All Sentinel options used by normalizeSentinelOptions exist in sentinel/types.d.ts:
sentinelRootNodes (28), maxCommandRediscovers (32), nodeClientOptions (38),
sentinelClientOptions (44), passthroughClientErrorEvents (72). No typos, no invented options.

(Cosmetic: RedisSentinelNodeClientOptions is Omit<…, keyof SentinelCommander>, and
SentinelCommander includes RESP, so setting RESP inside nodeClientOptions is type-redundant.
Harmless at runtime because the top-level RESP is what propagates.)

6.9 Cluster readiness and hash slots — FIXED

Key builders use explicit hash tags:

_docBase  = `${prefix}{editor:${b64(tenant)}:${b64(docId)}}:`
_statBase = `${prefix}{stat:${b64(tenant)}}:`
// global indexes: `${prefix}{editor:index}:documents` and friends

Every multi-key Lua script stays within one tag:

Script Keys Tag
ADD_PRESENCE / UPDATE_PRESENCE / GET_PRESENCE / REMOVE_PRESENCE presence set/hash/version {editor:T:D}
CLEAN_DOCUMENT 9 per-document keys {editor:T:D}
POP_EXPIRED / ACK_EXPIRED index, lease, claims {editor:index}
ADD_UNIQUE_USER / GET_UNIQUE_USERS / ADD_MONTH_USER expiry + info {stat:T}
SET_SHARD_COUNT / INCR_SHARD_COUNT / GET_SHARD_COUNT count + updated {stat:T}

base64url encoding of tenant and docId prevents a hostile or merely awkward identifier from
injecting {/} and breaking co-location. contract.tests.js asserts both the co-location and the
encoding.

Cluster EVAL routing is correct: base.js:532 picks normalized[3], which is KEYS[1] in
EVAL script numkeys key1 …. Every _eval call site passes at least one key, so the zero-key
mis-routing case does not arise.

6.10 cleanDocumentOnExit unconditional-delete concern — ADDRESSED

Now conditional on the presence hash being empty. See Major 2
for the remaining documentation gap — the behaviour itself is the safer choice.

6.11 Redis failure policy phase one — IMPLEMENTED AS DESCRIBED

// base.js:678-706
_checkAndLock   -> catch -> return false                              // fail closed
_checkAndUnlock -> catch -> return c_oAscUnlockRes.Locked             // fail closed
// editorStat.js:222-230
lockNotification -> catch -> return false                             // fail closed

Readers (getPresence, getLocks, getMessages, getForceSave,
getDocumentPresenceExpired, getForceSaveTimer) propagate rather than returning empty values, and
failure.tests.js asserts each of these. cleanDocumentOnExit aborts before the index operations if
the first eval throws, asserted by evalCalls === 1.

6.12 Redis replication failover limitation — ACCURATELY DOCUMENTED

Readme.md states:

Redis replication failover is not currently lock-safe. If Redis fails immediately after granting a
lock, the promoted replica may not have received that lock, allowing another client to acquire it.
[…] Sentinel support, command retries, and WAIT do not remove this limitation.

This is correct, and correct for the right reasons:

client SET lock NX PX  ->  primary accepts, replies OK
                           (replication is asynchronous — the replica may not have it)
primary fails
Sentinel/Cluster promotes the replica
second client SET lock NX PX  ->  succeeds on the new primary
=> the same lock is held twice

It applies to standalone-with-replica, Sentinel, and Cluster alike, because all three use
asynchronous replication. WAIT does not close it — WAIT is a durability barrier, not a consensus
primitive, and it cannot prevent a failover that promotes a replica which acked fewer writes. The
documentation claims no stronger guarantee than the code provides. This is a documented scope
limitation, not a blocker
; it does warrant an operational warning in deployment docs, which is a
reasonable follow-up rather than a merge gate.

6.13 pkg tracing of the six-file module — MECHANISM CORRECT

See §2. Manifest-driven pkg . from
DocService, pkg.scripts entry unchanged, static require chain through all six files.

6.14 Obsolete node-redis 4.7.0 concern — RESOLVED

DocService/package.json now pins "redis": "6.2.1"; ioredis is removed. 3DPARTY.md is updated
to match. No remaining require('ioredis') anywhere.


7. Minor issues

# Issue Location Detail
m1 CI path filters incomplete redisEditorDataTests.yml:8-25 Omits DocService/sources/editorDataRedis.js (the pkg shim) and DocService/sources/gc.js. gc.js implements the ACK half of the GC protocol; a gc.js-only change would skip these tests entirely.
m2 Asymmetric topology validation base.js:211-219 Sentinel throws on incomplete config (requires optionsSentinel.name / .sentinelRootNodes), but optionsCluster missing rootNodes silently falls back to standalone. The brief asks for loud failure on ambiguous config; Cluster does not comply.
m3 Reduced GC batch size base.js:73 POP_EXPIRED_BATCH_SIZE = 100. A large expired backlog drains at 100 per expire.documentsCron tick.
m4 Non-atomic index update on all topologies editorData.js:88-92, 105 The presence/index split is required for Cluster but applied unconditionally, so standalone also gets the window. Bounded and self-healing on the next presence write; the REMOVE_DOCUMENT_INDEX_SCRIPT score guard prevents deleting a fresher entry.
m5 One connection per instance base.js:630-632 EditorCommon constructs its own RedisConnection. DocsCoServer ×2 + routes/info.js:22 + notificationService.js:38 (+ proxy) ⇒ 4–5 connections per replica. close() only closes its own.
m6 command() client race base.js:522-538 Reads this.client after await this.connect(); a concurrent _abortClient() triggered by another command's timeout nulls it, producing a TypeError rather than a typed error. Narrow, and fail-closed in effect.
m7 Quota reads degrade to zeros routes/info.js:227-246 getEditorConnectionsCount, getLiveViewerConnectionsCount, getPresenceUniqueUser, getPresenceUniqueUsersOfMonth feed output.quota. A Redis error lands in the outer catch at line 271, leaving quota fields at their defaults — under-reporting rather than failing visibly.
m8 Stale comment canvasservice.js:1316 "clean redis (redisKeyPresenceSet and redisKeyPresenceHash removed with last element)" no longer describes what cleanDocumentOnExit does.
m9 ADD_MONTH_USER on a TTL-less key scripts.js:261-269 PTTL returns -1 for an existing key with no TTL, which re-triggers the ZADD + PEXPIRE branch. Only reachable if the TTL were externally cleared.

8. Full API audit

EditorData — 28 methods. "Tested?" = covered by an executable assertion somewhere in tests/redis/.

Method Atomic? Idempotent? Failure result Retry safe? Multi-replica safe? Tested?
addPresence Per-key script; index ZADD separate Yes throws SAFE Yes (bounded index window) Yes (process, presence)
updatePresence Same Yes throws SAFE Yes Yes (presence)
removePresence Yes (script) Yes throws SAFE Yes Yes (presence)
getPresence Yes (script) Read throws SAFE Yes Yes (process, presence)
getDocumentPresenceExpired Yes (script) Claim throws SAFE (lease) Yes Yes (expiration ×5)
removePresenceDocument 2 scripts, version-guarded Yes throws SAFE Yes (score CAS) Yes (process, presence)
lockSave Yes (script) Yes (same token) false SAFE Yes† Yes (process, failure)
unlockSave Yes (script) Yes Locked SAFE Yes Yes (process, failure)
lockAuth Yes (script) Yes false SAFE Yes† Yes (failure)
unlockAuth Yes (script) Yes Locked SAFE Yes Yes (failure)
addLocks Yes (script) Yes (HSET) throws SAFE Yes Yes (process)
addLocksNX Yes (script) Yes (HEXISTS+HSET) throws SAFE Yes Yes (process, edge)
removeLocks Yes (script) Yes (HDEL) throws SAFE Yes Yes (process)
removeAllLocks Yes (DEL) Yes throws SAFE Yes No
getLocks Yes Read throws SAFE Yes Yes (process, failure)
addMessage Yes (script) No (RPUSH) throws UNSAFE Yes Yes (process)
removeMessages Yes (DEL) Yes throws SAFE Yes No
getMessages Yes Read throws SAFE Yes Yes (process, failure)
setSaved Yes (SET EX) Yes throws SAFE Last-writer-wins Yes (process)
getdelSaved Yes (script) No (consumes) throws UNSAFE Yes Yes (process) — happy path only
setForceSave Yes (script) Yes (full overwrite) throws SAFE Last-writer-wins Yes (process, edge)
getForceSave Yes (HMGET) Read throws SAFE Yes Yes (process, failure)
checkAndStartForceSave Yes (script) Yes (started guard) throws SAFE Yes (one winner) Yes (process ×2 procs)
checkAndSetForceSave Yes (script) Yes (time/index CAS) throws SAFE Yes (stale rejected) Yes (process, edge)
removeForceSave Yes (DEL) Yes throws SAFE Last-writer-wins No
cleanDocumentOnExit Script + 2 follow-ups Yes throws (aborts early) SAFE Yes (presence-guarded) Yes (edge, presence)
addForceSaveTimerNX Yes (ZADD NX) Yes throws SAFE Yes (first write wins) Yes (process)
getForceSaveTimer Yes (script) Claim throws SAFE (lease) Yes Yes (expiration ×5)

† Subject to the documented replication-failover limitation (§6.12).

EditorStat — 28 methods. Rows collapse generated families (e.g. the three
set*ConnectionsCountByShard variants share one row).

Method Atomic? Idempotent? Failure result Retry safe? Multi-replica safe? Tested?
addPresenceUniqueUser / …ViewUser Yes (script) Yes throws SAFE Yes Yes (process, edge)
getPresenceUniqueUser / …ViewUser Yes (script) Read+evict throws SAFE Yes Yes (process)
addPresenceUniqueUsersOfMonth / …View… Yes (script) Yes throws SAFE Yes Yes (process, edge)
getPresenceUniqueUsersOfMonth / …View… No (ZREM + ZRANGE + MULTI) Read throws SAFE Yes Yes (process, edge)
setEditorConnections Yes (script) Yes (unique member) throws SAFE Yes (per-shard sampleId) Yes (edge)
getEditorConnections Yes Read throws SAFE Yes Yes (edge)
set{Editor,Viewer,LiveViewer}ConnectionsCountByShard Yes (script) Yes (HSET) throws SAFE Yes Yes (shard-expiration)
incr{…}ConnectionsCountByShard Yes (script) No (HINCRBY) throws UNSAFE Yes Yes (shard-expiration)
get{…}ConnectionsCount Yes (script) Read+evict throws SAFE Yes Yes (shard-expiration)
addShutdown Yes (SADD) Yes throws SAFE Yes No
removeShutdown Yes (SREM) Yes throws SAFE Yes No
getShutdownCount Yes (SCARD) Read throws SAFE Yes No
cleanupShutdown Yes (DEL) Yes throws SAFE Yes No
setLicense Yes (HSET) Yes throws SAFE Last-writer-wins No
getLicense Yes (HGET) Read throws SAFE Yes No
removeLicense Yes (HDEL) Yes throws SAFE Yes No
lockNotification Yes (SET NX PX) Yes false SAFE Yes Yes (process, failure)
deleteKey Yes (DEL) Yes throws SAFE Yes No — see Major 4

Do any of these statistics actually matter?

The brief asks not to dismiss anything as "only statistics". They are not:

  • Licensing / quota — routes/info.js:227-246 feeds output.quota.edit.connectionsCount,
    output.quota.view.connectionsCount, usersCount.unique, usersCount.anonymous and
    quota.byMonth from getPresenceUniqueUser, getEditorConnectionsCount,
    getLiveViewerConnectionsCount and getPresenceUniqueUsersOfMonth. These are the licence-facing
    numbers.
  • Administrative behaviour — getShutdownCount gates the shutdown drain loop
    (shutdown.js:72-74).
  • incr*ConnectionsCountByShard is not retry-safe, but self-heals: DocsCoServer.js overwrites
    each shard's authoritative count with set*ConnectionsCountByShard on every expireDoc tick, so
    HINCRBY drift cannot accumulate. INCR_SHARD_COUNT_SCRIPT additionally clamps negatives to zero.
  • Stale-shard decay works: SET/INCR refresh both the count hash and the shard heartbeat ZSET
    with expire.shard (300 s, new in this PR), and GET_SHARD_COUNT_SCRIPT evicts shards whose
    heartbeat is older than that window before summing.

9. Failure policy matrix

Method Current policy Correct? Retry safe? Remaining concern
lockSave / lockAuth false on error Yes SAFE A lost reply leaves the lock held until TTL — liveness, not safety
unlockSave / unlockAuth Locked on error Yes SAFE —
lockNotification false on error Yes SAFE —
getPresence / getLocks / getMessages / getForceSave propagate Yes SAFE —
getDocumentPresenceExpired / getForceSaveTimer propagate Yes SAFE —
addPresence / updatePresence propagate Yes SAFE Index ZADD non-atomic; bounded, self-heals
removePresence / removePresenceDocument propagate Yes SAFE —
addLocks / addLocksNX / removeLocks / removeAllLocks propagate Yes SAFE —
addMessage propagate Tolerable UNSAFE (RPUSH) A retry duplicates a message; no caller retries today
setSaved propagate Yes SAFE —
getdelSaved propagate No UNSAFE Major 1 — needs an "unknown" state
setForceSave propagate Yes SAFE —
checkAndStartForceSave / checkAndSetForceSave propagate Yes SAFE (CAS) —
removeForceSave propagate Yes SAFE Stale remove can drop a newer record (same as memory)
cleanDocumentOnExit propagate, aborts before index ops Yes SAFE Silent no-op divergence (Major 2)
set*ConnectionsCountByShard propagate Yes SAFE —
incr*ConnectionsCountByShard propagate Tolerable UNSAFE Self-heals via periodic set* overwrite
addShutdown / removeShutdown / cleanupShutdown propagate Yes SAFE —
setLicense / removeLicense propagate Yes SAFE —
deleteKey propagate Questionable SAFE Guessed key format (Major 4)

Is the partial failure policy internally safe? Broadly yes. The two decisions that can be
irreversible are lock acquisition and destructive cleanup, and both fail closed: locks deny, and
cleanDocumentOnExit throws before reaching the index/timer removals. The one place where a Redis
failure can still cause a decision on false information is getdelSaved, because null means both
"nothing recorded" and "we lost the answer".


10. Lost-response / retry classification

Applying the brief's T0–T4 scenario to every mutating method:

SAFE TO RETRY — lockSave, lockAuth (same fencing token re-acquires), unlockSave,
unlockAuth, lockNotification, addPresence, updatePresence, removePresence,
removePresenceDocument, addLocks, addLocksNX, removeLocks, removeAllLocks,
removeMessages, setSaved, setForceSave, removeForceSave, addForceSaveTimerNX,
cleanDocumentOnExit, set*ConnectionsCountByShard, setEditorConnections, addShutdown,
removeShutdown, cleanupShutdown, setLicense, removeLicense, deleteKey, the unique-user and
monthly-user writers.

UNSAFE TO RETRY

  • addMessage — RPUSH appends; a retry duplicates. No caller retries today, so this is latent.
  • incr*ConnectionsCountByShard — HINCRBY accumulates; mitigated by the periodic authoritative
    set* overwrite.

REQUIRES CLAIM/ACK PROTOCOL — getDocumentPresenceExpired, getForceSaveTimer.
Already implemented correctly (§6.3).

REQUIRES READ-BACK or an explicit "unknown" state — getdelSaved. Not implemented (Major 1).

REQUIRES VERSION — removePresenceDocument and cleanDocumentOnExit's index removal.
Already implemented via the presence-version CAS in REMOVE_DOCUMENT_INDEX_SCRIPT.

The general point the brief makes holds here and the implementation mostly respects it: "Redis
command is atomic" does not mean the application operation is retry-safe.
getdelSaved is the one
place where that gap is still open.


11. Key and schema review

Key Scope Hash tag TTL Notes
{editor:T:D}:presence:set document doc expire.presence ZSET userId → expireAt
{editor:T:D}:presence:hash document doc expire.presence HASH userId → info
{editor:T:D}:presence:version document doc expire.presence CAS token for index removal
{editor:T:D}:savelock / :lockdocument document doc ttl arg (PX) fencing token
{editor:T:D}:locks document doc expire.locks HASH lockId → JSON
{editor:T:D}:message document doc expire.message LIST
{editor:T:D}:saved document doc expire.saved STRING
{editor:T:D}:forcesave document doc expire.forcesave HASH, 10 fields
{editor:index}:documents global index none pruned by GC
{editor:index}:forcesavetimer global index none pruned by GC
{editor:index}:*:expired:lease global index none GC lease ZSET
{editor:index}:*:expired:claims global index none GC claim HASH
{stat:T}:presence:unique:{edit,view}:{expiry,info} tenant stat none —
{stat:T}:presence:month:{edit,view}:index / :<b64period> tenant stat expire.monthUniqueUsers fixed at creation
{stat:T}:editorconnections tenant stat none trimmed by score
{stat:T}:connections:<type>:{count,updated} tenant stat expire.shard (300 s) new in this PR
{stat:T}:notification:<b64type> tenant stat ttl arg SET NX PX
caller-supplied (shutdown, licence) caller none none used verbatim, correctly un-prefixed

Assessment against the brief's checklist:

  • Scope — correct; documents are tenant+doc scoped, stats tenant scoped, indexes global.
  • Hash slots — correct; no multi-key script crosses a tag (§6.9).
  • Co-location — required groups share a tag.
  • TTLs — every per-document key carries one, so an allkeys-* eviction policy is not the only
    thing keeping the keyspace bounded. The global indexes deliberately have none and rely on GC.
  • Can stale state overwrite fresh state? — guarded where it matters:
    REMOVE_DOCUMENT_INDEX_SCRIPT compares the presence version, SET_FORCE_SAVE_SCRIPT compares
    time/index, ACK_EXPIRED_SCRIPT compares the claim token, addForceSaveTimerNX uses ZADD NX.
    The remaining unguarded case is the plain ZREM forceSaveTimerKey at editorData.js:337, which
    could drop a timer re-added between the clean script and the ZREM. Narrow.
  • Can cleanup delete newer state? — no, because of the presence-empty guard plus the version CAS.
  • Cross-tenant interference — no; tenant is base64url-encoded into every tag.

12. Test quality audit

What genuinely proves an invariant

Invariant Test that proves it
Stale ACK cannot remove a newer claim expiration.tests.js — 'does not let a stale document-presence acknowledgement remove a retry' (and the force-save twin)
A crashed worker's unprocessed items come back 'recovers only the unacknowledged document-presence item after a partial batch'
A lost reply does not lose expired work 'recovers a document-presence entry after a discarded response', '…after a client timeout'
Exactly one replica starts a force-save process.tests.js:444-449, across two real OS processes
A stale force-save update cannot overwrite process.tests.js:450-460
Cleanup does not delete a live document presence.tests.js — 'document cleanup does not delete presence while a user remains'
The surviving replica stays usable after SIGKILL process.tests.js:577
Redis and memory agree where they should process.tests.js:551 differential trace
Cluster co-location holds contract.tests.js — 'isolates document keys and co-locates one document for Redis Cluster'
Client resets after a command timeout editorDataRedis.tests.js — 'resets the client after a command timeout'

That is a genuinely good set. The claim/lease/ACK protocol in particular is tested against exactly
the adversarial sequences that matter.

Where the confidence is false

Pattern Where Consequence
Mocks Redis instead of failing it failure.tests.js — patches _eval / _command Only tests "failed before mutation"; never a real network fault, kill, or pause
Tests only successful commands most of edge, presence, contract —
Does not simulate lost responses outside GC everywhere except expiration.tests.js and one addPresence case getdelSaved, setSaved, addMessage, force-save all unverified under commit-then-lost-reply, despite holdAfterCommand making it cheap
Mocks failover nothing tests failover at all The documented lock limitation is untested, as is Sentinel promotion
Single process all suites except process.tests.js —
Skipped under Cluster process.tests.js:547-548 (describeStandalone) No cross-process verification on Cluster

Remaining test gaps by layer

  • Unit — none material.
  • Integration — cleanDocumentOnExit conditional semantics not pinned against
    editorDataMemory; removeAllLocks, removeMessages, removeForceSave, shutdown tracking and
    licence storage have no assertions at all.
  • Multi-process — only two tests; skipped under Cluster; oracle coverage gaps listed in §6.5.
  • Failure injection — no test kills or pauses a real Redis; no lost-reply coverage outside GC.
  • Cluster — job exists, last run failed on infrastructure, has not re-run; process tests skipped.
  • Sentinel — no integration coverage of any kind (Major 3).
  • Package / E2E — correctly designed, never executed, and pins optionsCluster/optionsSentinel
    to {} so it cannot catch entrypoint config drift.

13. Requirements compliance

Against issue #267 as the specification.

Requirement Status Evidence
editorDataRedis module exists and resolves Fully implemented DocService/sources/editorDataRedis.js + six-file module
Module traced into the pkg binary Implemented, unverified pkg.scripts entry + static require chain; no executed build
Packaged DocService starts with editorDataStorage: "editorDataRedis" Unverified pkg-e2e.sh exists and is well-designed; never ran (Blocker 1)
Works from minimal redis: {host, port} in local.json Fully implemented default.json supplies options/optionsCluster/optionsSentinel; hasNodeCluster/hasNodeSentinel both false ⇒ standalone
Docker and native builds consistent Fully implemented Both use pkg . from DocService
Session state survives DocService restart Fully implemented Redis-backed presence, locks, messages, saved, force-save
Compatible with existing deployment config Contradicted for Sentinel iooptions vs optionsSentinel (Blocker 2)
editorDataStorage default unchanged Fully implemented still editorDataMemory

Ambiguous / not specified by #267 (decided by the PR, worth an explicit maintainer sign-off):

  • Whether Cluster and Sentinel are in scope at all — #267 says nothing about them.
  • Whether deleteKey should be implemented or remain a no-op.
  • Whether cleanDocumentOnExit may become conditional.
  • What the failure policy should be for the ~14 methods still outside phase one.

14. Security and isolation

Area Assessment
Credentials Read from config (options.user/password, Sentinel sentinelClientOptions). Never logged — _createClient's debug line prints only connector, cluster flag, host, port, database.
TLS Passed through via socket options; not independently validated, and untested for Sentinel.
Configuration logging Clean; no credential leakage found.
Key construction Good. encodePart base64url-encodes tenant and docId, so neither {/} nor : from a hostile identifier can break hash tags or forge another tenant's key. contract.tests.js asserts this, including a tenant:{}:/世界 case.
Tenant isolation Enforced structurally by the tag; edge.tests.js asserts save locks are isolated across tenants with identical doc IDs.
Malicious key values Lock values and messages are JSON-encoded on write and jsonDecoded with a fallback on read, so malformed data degrades to null rather than throwing.
Cross-database operations The EditorStat(database) proxy path opens a separate connection on the configured logical DB, and deleteKey issues an unprefixed DEL there — see Major 4.
Prefix cfgRedisPrefix is not encoded, but it is administrator-controlled config, not user input.

15. Final assessment

Needs significant changes

To be clear about what that means: the implementation itself is close. The remaining work is
verification and a config bridge, not a rewrite. The distributed-systems design — claim/lease/ACK,
hash-tag co-location, opaque force-save payloads, fail-closed locks, per-claim tokens — is sound and,
where CI actually runs, well tested.

Required before merge

  1. Approve and run both workflows on ec7900f5; get them green, especially the packaged smoke test.
    This is non-negotiable for a packaging bug.
  2. Resolve the iooptions → optionsSentinel mismatch: bridge it in code, land the companion
    DocumentServer change, or drop the Sentinel path.

Should be decided before merge

  1. getdelSaved retry semantics — introduce a distinct "unknown" result.
  2. Document the cleanDocumentOnExit divergence and fix the stale comment at canvasservice.js:1316.
  3. Decide whether deleteKey should really delete.

Cheap follow-ups

  1. Add a Sentinel CI job.
  2. Extend holdAfterCommand to getdelSaved, setSaved and addMessage.
  3. Add DocService/sources/gc.js and the pkg shim to the workflow path filters.
  4. Add an operational warning about the replication-failover lock limitation to deployment docs.

Appendix A — CI evidence

PR #46 HEAD = ec7900f516271e4e42a1fb029f09a05d06d747fc
base = main (merge-base abfa2946fcc7b6f31df497f73d7e48ca6dbef16b), 18 commits, 32 files

$ gh run list --branch feature/redis-editor-data-storage
2026-09-23T18:13  5e3043c1  failure           Redis editor data tests
2026-09-23T18:13  5e3043c1  success           E2E (overlay on nightly)
2026-09-23T17:51  ee229022  action_required   E2E (overlay on nightly)
2026-09-23T17:51  ee229022  action_required   Redis editor data tests
2026-09-23T17:43  913378ad  action_required   Redis editor data tests
2026-09-23T17:43  913378ad  action_required   E2E (overlay on nightly)
2026-09-23T16:22  1f9f803d  failure           Redis editor data tests
2026-09-23T16:22  1f9f803d  success           E2E (overlay on nightly)
2026-09-21T14:36  cf0d7200  action_required   (both)
2026-09-21T14:15  e1a4ad50  action_required   (both)
2026-09-18T15:37  4466ee4d  action_required   (both)

$ gh run list --commit ec7900f5...
Redis editor data tests   -> action_required
E2E (overlay on nightly)  -> action_required

$ gh run view 35901026235 --json jobs
Redis Cluster editor data tests: failure
Redis editor data tests:         success

$ gh api .../jobs/107501320437/logs | tail
/home/runner/work/_temp/....sh: line 13: redis-cli: command not found      (×30)
##[error]Process completed with exit code 1.

$ git ls-tree 5e3043c1 -r --name-only | grep -i pkg
tests/redis/editorDataRedis.pkg-smoke.js          # pkg-e2e.sh absent

Only the DCO check is currently passing on the PR.


Appendix B — node-redis 6.2.1 verification

Downloaded redis@6.2.1 and @redis/client@6.2.1 and read the source rather than trusting commit
messages.

dist/lib/cluster/cluster-slots.js:465   options.RESP = this.#options.RESP;
dist/lib/cluster/cluster-slots.js:550   RESP: this.#options.RESP,
  => top-level cluster RESP propagates to every node client.
  => normalizeClusterOptions setting options.RESP = 2 and deleting defaults.RESP is CORRECT.

dist/lib/sentinel/index.js:600-601      // first take the globally set RESP
                                        RESP: this.#RESP,
  => Sentinel likewise propagates.

dist/lib/sentinel/index.js:561          this.#maxCommandRediscovers = options.maxCommandRediscovers ?? 16;
dist/lib/sentinel/index.js:720          if (++count > this.#maxCommandRediscovers) { ... }
  => maxCommandRediscovers: 0 gives up on the first attempt; no replay of a committed command. CORRECT.

dist/lib/sentinel/types.d.ts:28         sentinelRootNodes: Array<RedisNode>;
dist/lib/sentinel/types.d.ts:32         maxCommandRediscovers?: number;
dist/lib/sentinel/types.d.ts:38         nodeClientOptions?: RedisSentinelNodeClientOptions<...>;
dist/lib/sentinel/types.d.ts:44         sentinelClientOptions?: RedisSentinelNodeClientOptions<...>;
dist/lib/sentinel/types.d.ts:72         passthroughClientErrorEvents?: boolean;
  => all option names used by normalizeSentinelOptions are real. No typos.

redis@6.2.1 dependencies: @redis/bloom, @redis/client, @redis/json, @redis/search, @redis/time-series
  => the Redis-Stack sub-packages are still pulled in, which inflates the pkg binary.
     Not a correctness issue against a plain Redis/Valkey target; worth noting for binary size.

@j-base64

Copy link
Copy Markdown

This PR is moving at lightning speed 😊
Thanks for the work on this!

I started a deep review (at head ec7900f5), but since the previous review landed in the meantime (very thorough and looks right), I cross-checked against it to avoid duplication.

I went looking from the angle of how we would want to run it in a multi-replica setup and general robustness, focusing only on things that surfaced in the analysis and were not already raised:

  • Force-save on the command/button path, across replicas.
    The timer-driven force-save looks protected by the claim/lease protocol. The command/button-driven path (DocsCoServer.js:980-981 -> checkAndSetForceSave then checkAndStartForceSave, the SET_FORCE_SAVE_SCRIPT at scripts.js:233) seems to issue the reset and the start as two separate calls with no lock around the pair. If two replicas drive a force-save on the same document at the same time, is there a chance both start a convert for the same (time, index)? I am not sure I am reading the interleaving right, so mostly flagging it as a point the author should check. Related: checkAndStartForceSave (editorData.js:298) commits started=1 server-side but throws if the reply is lost, which looks like it could leave the document stuck until the TTL expires.

  • Two stat keys look like they have no TTL.
    The editorconnections sample ZSET (editorStat.js:136) is pruned only on write via ZADD + ZREMRANGEBYSCORE, and the month index ZSET in ADD_MONTH_USER_SCRIPT (editorStat.js:89) is pruned only on the read path. Every other key here is TTL'd. Under a non-evicting maxmemory-policy, a key with no TTL on an idle tenant would just accumulate, so it would be nice to keep these consistent with the rest. A plain EXPIRE / PEXPIRE would do it.

  • Presence is not compared against the in-memory backend in the differential test.
    The trace that is deep-compared to the memory backend (the runTrace sequence in process.tests.js, asserted against the memory run) covers the save locks and the unique-user stats, but the document presence ops appear only in the Redis-only scenarios with hand-written assertions. Presence is one of the headline multi-replica features, so having it covered by the same cross-backend comparison would give a lot of confidence, even a narrowed shape-level one.

  • A build check for packaging.
    It would be good if the build failed when a pkg.scripts entry matches no file (DocService/package.json:45), since that is the kind of thing that silently drops a module from the packaged binary. The module-specific smoke test covers this module's case, but a generic check that every pkg.scripts glob resolves to at least one file would catch the whole class.

  • A bit more operator-facing documentation.
    The failover limitation is documented well, and the TTL defaults and key prefix are in default.json. What I could not find is the minimum Redis/Valkey server version, the maxmemory-policy / eviction assumption the TTL-everywhere design relies on, and a single place describing the key schema (it currently lives implicitly in base.js). A short section covering those three would help anyone provisioning the server.

Hope these insights can help, and that the entrypoint config question you raised can be clarified soon, @nvanlaerebeke.

Really nice progress overall 🤙

@nvanlaerebeke

Copy link
Copy Markdown
Author

@j-base64 , the force-save was a problem, I created some tests that verified that there were issues before I applied a fix

@nvanlaerebeke

nvanlaerebeke commented Sep 25, 2026 •

Copy link
Copy Markdown
Author

@j-base64 , I went over your points first as the changes were not too big.

Thanks for the thorough review and for cross-checking everything against the previous feedback 😊

I went through each point and pushed follow-up changes:

  • Command-path force-save race: addressed in 926ca41. The Redis-side reset now refuses to clear an already active force-save, so a competing replica cannot reset and start the same conversion. I also added coverage for the multi-replica interleaving, failed-conversion retry, lost start responses, and the resulting UnknownError from DocsCoServer.

  • Statistic keys without TTL: fixed in 44a853d. Both the editor-connections and monthly presence index ZSETs now receive refreshed TTLs atomically on writes. The tests cover initial expiry, TTL refresh, idle cleanup, pruning, and returned statistics.

  • Presence differential coverage: added in 2968e61. The process-based tests now compare the Redis presence shape against the memory backend, while keeping the Redis-only lifecycle and persistence scenarios separate.

  • Packaging check: I agree this would be useful. The server project now has a packaged editorDataRedis smoke test that verifies module resolution and runtime health, but the generic check that every pkg.scripts entry resolves to at least one file is not implemented yet. That check belongs in the DocumentServer build/packaging orchestration and is broader than Redis support, so I suggest tracking it in a separate ticket as a general build improvement.

  • Operator documentation: covered in 32a2f3b. Because Valkey was mentioned several times I added test coverage for that as well together with the minimum and latest versions.
    The README now documents the Redis/Valkey version baseline, tested versions and topologies, the noeviction requirement, TTL defaults, key prefixes/schema, and the existing replication failover limitation. The CI matrix was also expanded to cover Redis 7.0.15/7.2/8 and Valkey 7.2/8 across standalone, Cluster, and Sentinel.
    About the 7.0.15, it's not officially supported by the node-redis package, I've included it because the ubuntu 24.04 redis version that is installed is 7.0.15.

Thanks again for the careful review.

@nvanlaerebeke

Copy link
Copy Markdown
Author

Took me a bit to get it into a state I liked, I've opened up a PR (Euro-Office/DocumentServer#386) on the DocumentServer project with the changes for the sentinel configuration:

Euro-Office/DocumentServer#386

It also includes easy to run commands to validate the setups for both Redis and Valkey in:

  • Standalone
  • Cluster
  • Sentinel

So at the time of writing I've been able to test each topology from a locally build image both with redis and valkey.

I'll go over the remaining items next

@moodyjmz

Copy link
Copy Markdown
Member

Thanks for the connection-layer work and the failure-handling tests. One regression in the connection layer needs fixing:

A single failed connect disables Redis for the life of the process. At 85c8fb3c, the catch in _connect() sets this.closed = true (redisConnection.js:231). _withOperation() (:348-350) rejects any call while closed is true, before it tries to connect, and closed is only reset in _createClient(), which is only reached through _withOperation(). RedisConnectionManager.acquire() keeps returning the same object. From then on every store sharing that connection (editorData, editorStat, info, notificationService) throws RedisUnavailableError until the process restarts.

Fix: keep closed for deliberate shutdown (close(), SIGTERM). On a failed connect, drop the client and let the next operation connect again.

Details
  • Came in with 6b817086 ("refactor(redis): centralize editorDataRedis connection ownership"). base.js before it had no closed flag.
  • Repro (standalone): docker stop Redis, one call (ECONNREFUSED after ~240 ms), docker start. Every later call fails with Redis connection is closed, with no connect attempt. A fresh RedisConnection in the same process gets PONG.
  • It also latches at startup: if editorData.connect() fails once, the error is logged and nothing retries (DocsCoServer.js:4316-4323).
  • Current tests don't catch it. The connect-failure cases in connection.tests.js stop after the failure. The reconnect cases in editorDataRedis.tests.js / expiration.tests.js pause for less than the 15 s connect timeout, so their reconnect succeeds. failure.integration.tests.js stops Redis and never restarts it.
  • A test that fails at 85c8fb3c and passes with L231 removed (needs a Redis on the test port):
test('a failed connect() does not prevent the next command() from connecting', async () => {
  const connection = new RedisConnection();
  connection.connector = 'redis';
  connection.cluster = false;
  connection.client = fakeClient({
    isOpen: false,
    isReady: false,
    connect: async () => {
      throw new Error('Connection timeout');
    }
  });

  await assert.rejects(connection.connect(), /Connection timeout/);
  try {
    assert.equal(await connection.command(['PING']), 'PONG');
  } finally {
    await connection.close();
  }
});

fakeClient is an EventEmitter with isOpen/isReady set and the given properties merged in.

@moodyjmz

Copy link
Copy Markdown
Member

Proposal: land #46 as a stack of smaller PRs rather than one. It is currently +7,533 lines across 56 files, and at that size a problem in one area holds up everything else. Feedback welcome, from the author and from anyone else working on this code, before anything gets cut.

A stack keeps one branch to test: the top one carries everything below it, so anyone building #46 today would build that branch instead.

Rough shape, bottom first:

  1. Connection core, failure-handling tests, CI
  2. Locks
  3. Document state
  4. Force-save
  5. Presence and the shared sweep
  6. Shutdown handling, enablement, docs

Open questions:

  • Is this the right cut? Different boundaries, or fewer PRs, are fine if they keep each PR reviewable on its own.
  • Where the branches live. Each PR's base must be a branch in Euro-Office/server, so the lower branches have to exist here, not only on a fork.
Details
  • Before cutting: agree the per-method failure policy (which methods throw and which return a default when Redis is unavailable; getdelSaved must throw), and freeze the EditorCommon call surface (_eval/_command/_commands) and its error contract. Every PR above the first calls that seam. Changing it later touches all of them.
  • One module per method group. Right now all groups sit in editorData.js and scripts.js, so every restack would conflict across sibling PRs.
  • Outside the stack: the editorDataMemory.js, canvasservice.js and savedState.js changes in their own PR, landed first.
  • Mechanics: restack with git rebase --update-refs from the top branch and push --force-with-lease. No squash merges; delete each branch on merge so GitHub retargets the next PR.
  • Review order: the first PR gets reviewed to merge. The rest stay as drafts until the one below them merges.

@MonaAghili

MonaAghili commented Sep 29, 2026 •

Copy link
Copy Markdown

I re-checked 85c8fb3 against the discussion above and only list what hasn't been raised yet. Items marked reproduced were run against node-redis 6.2.1 (the pinned version) with this PR's own modules, using a local TCP proxy for outages and latency and a small fake Sentinel; the rest were traced in the code. Entrypoint and dev-stack points are on DocumentServer #386.

Not raised yet

1. Regression on the default in-memory backend: every final save is treated as failed (reproduced)
setSaved() is only written for a non-'1' status, so on a normal save nothing is stored and the in-memory getdelSaved() returns undefined (editorDataMemory.js#L194-L199). consumeSavedState() only treats null as "nothing saved" (savedState.js#L12-L21); the old code used null == savedVal. I ran the real commandSfcCallback with editorDataMemory, mocking only the database, storage and HTTP. After the integrator answered {error: 0}, the file was copied to forgotten-files storage, the task status was restored to SaveVersion, and updateVersion went out with success: false. This hits every deployment on the default configuration.
Suggested fix: savedValue == null (or make the memory backend return null), plus a test that uses the real memory backend instead of mocking getdelSaved.

2. SIGTERM, SIGINT and uncaughtException now hang while any editor is connected (reproduced)
shutdownRedis() awaits server.close() (server.js#L499-L552), which never calls back while socket.io connections are open; I tested with socket.io 4.8.1 and DocService's socket.io configuration. uncaughtException used to exit with code 1 immediately. Now the process stops accepting connections but keeps running until the last editor leaves. This hits every deployment.
Suggested fix: close the socket.io connections and/or cap the wait with a timeout, and keep the immediate exit for uncaughtException.

3. Sentinel mode runs commands one at a time (reproduced)
node-redis 6.2.1's Sentinel client leases its single master client (masterPoolSize 1) for each operation, so concurrent commands run serially (redisConfig.js#L98-L139). With 50 ms of latency, 200 concurrent PINGs took 10.2 s, against 52 ms with reserveClient: true. Through the adapter, 1,415 of 2,000 concurrent pings hit the 30 s timeout; standalone finished all 2,000 in 0.4 s.
Suggested fix: reserveClient: true (or a larger pool), plus a concurrency test.

4. Lost presence entries are never rebuilt, so live editors can vanish
UPDATE_PRESENCE_SCRIPT only refreshes an entry that still exists (scripts.js#L38-L47), so a connected user whose entry disappears is never re-added. An entry can disappear through:

  • a Redis restart or data loss;
  • a refresh gap longer than the TTL;
  • the cross-node reconnect race in REMOVE_PRESENCE_SCRIPT, which deletes by user id with no connectionId check (#L60-L64).

The next editor who leaves then triggers the final save and deleteChanges while others are still editing. GC also treats the document as expired, and publish() can skip pub/sub for remote users. Traced in the code, not reproduced.
Suggested fix: re-add missing entries on refresh (expireDoc has the connection info), and only remove an entry when its connectionId matches.

5. The Helm chart can't configure Sentinel for this PR
Adding to Blocker 2 in my earlier review, whose entrypoint half #386 addresses. The Kubernetes-Docs chart only emits REDIS_SENTINEL_* when redisConnectorName is ioredis, and this PR rejects that name with Unsupported Redis connector (redisConnection.js#L130-L133). The chart also joins the node lists with spaces, which #386 now splits on commas only (details there). This needs a companion Kubernetes-Docs change.

6. Two additions to @moodyjmz's startup-latch point

  • server.listen() only runs after that connect succeeds (DocsCoServer.js#L4316-L4322), so the process stays up but never listens. Configuration errors, such as setting both Cluster and Sentinel or a name other than redis, end the same way.
  • Correction to my earlier review, where I called maxCommandRediscovers: 0 correct: node-redis uses the same limit for its Sentinel topology-discovery loop (redisConfig.js#L136). A single transient answer, such as s_down mid-failover, therefore fails connect() with no retry. I reproduced this with a fake Sentinel; node-redis's default setting connected after 1.2 s. Once the latch is fixed, the initial connect still needs its own retry.
Should also be fixed (medium)
  • documentsCron vs fixed TTLs. Presence and shard counters are only refreshed once per documentsCron step (DocsCoServer.js#L167), which admins can change in AdminPanel. expire.presence and expire.shard are fixed at 300 s and blocked by the config schema, so a cron of 5 minutes or more makes live documents look expired. Validate the pair.
  • One slow command now fails everything in the process (reproduced). Destroying the client on timeout is the right cure for half-open sockets, as discussed above. But since 6b81708 all stores share one client, so a 30 s timeout on any single command (redisConnection.js#L388-L397) fails every in-flight operation across editorData, editorStat, info and notifications.
  • GC starvation. checkDocumentExpire handles the claimed batch inside one try (gc.js#L146-L174). With the at-least-once claims, a document that always throws comes back in the same position and blocks the documents behind it on every reclaim. Catch errors per item.
  • Abandoned saved-state claims. The claim added in 2579a95 has no TTL (scripts.js#L181-L208) and relies on the same operation being retried. As noted in my earlier review, though, receiveTask acks in finally (canvasservice.js#L2113), so a task that throws is never redelivered. An abandoned claim then makes every later final save of that document throw SavedStateUnknownError. getdelSaved() without an operation id also creates a claim nobody can acknowledge.
  • CI follow-ups.
    • The workflow's path filter (redisEditorDataTests.yml#L7-L29) misses the files changed since the last round: savedState.js, canvasservice.js, DocsCoServer.js, server.js, editorDataMemory.js and routes/info.js.
    • The unit workflow never runs tests/redis; it only selects tests/unit.
    • The Cluster failover test is a graceful CLUSTER FAILOVER, and the Sentinel test waits for promotion before sending anything.
    • The pkg smoke test covers standalone only.
Low / cleanup
  • The force-save "no reset while active" guard exists only in the Redis backend, so the memory backend still allows the duplicate start. On Redis, a rejected request returns UnknownError rather than "in progress".
  • The 30 s command and 15 s connect timeouts can't be tuned: they apply regardless of commandOptions.timeout and socket.connectTimeout.
  • Sentinel node clients inherit redis.options credentials, but Cluster defaults don't. redis.options.url is silently dropped under Sentinel.
  • The proxy store with a non-zero database on Cluster only fails when first used, at pre-stop.
  • Lock and unlock errors are swallowed without logging. Logs print host/port for Cluster and Sentinel, and every reconnect attempt logs a full stack at error level.
  • AdminPanel creates a Redis-backed EditorStat through routes/info but doesn't declare redis.
  • server.js now requires the Redis connection manager at the top, so memory-only deployments load node-redis too.
  • editorStat.js ends with an Ascensio copyright block below the Euro-Office SPDX header, and the editorDataRedis.js shim has no SPDX header.
  • README:
    • it says GC cleanup runs "every 2 seconds (up to 50 members/second)", but the default is every 120 s (about 0.8 per second);
    • the key list omits saved:claim, and the claim and lease keys have no TTL, contrary to "refreshed or expired by the backend";
    • the optionsSentinel structure from your configuration comment isn't in the README yet.

Rebuild missing presence entries during valid heartbeats, guard removals by
connection ID, and keep the document presence index synchronized across
replicas, TTL gaps, and cleanup.

Add Redis-backed regression coverage for recovery, reconnect races, replica
coordination, and live-user cleanup/GC behavior.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Add dedicated TTLs and legacy migration for saved-state claims, make
acknowledgements idempotent, and prevent stale workers from deleting newer
saved state. Expand Redis and callback regression coverage.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
- retry initial Sentinel discovery without replaying commands
- support concurrent Sentinel operations with reserved clients
- recover existing clients after Sentinel failover
- validate Redis and Sentinel credentials and topology settings
- expand authenticated, unauthenticated, ACL, Cluster, and failover coverage
- document Sentinel connection and authentication behavior

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Serialize editor-data operations so a timed-out command can safely abort its
physical Redis client without poisoning unrelated operations.

Document the tradeoff: a slow command can temporarily hold up other editor-data
work, and Cluster batches are no longer fully parallel. A pool of Redis clients
could reduce that impact, but would be a much larger and riskier change across
the supported topologies.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Process expiry claims independently so failed documents do not block
successful items, while preserving Redis lease-based retries. Validate
document expiry scheduling against presence and shard TTLs with scheduling
headroom and cover the GC entry point with regression tests.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Preserve the duplicate-start guard while returning NoError with inProgress
for Form and Internal force-save callers. Keep normal editor failures and
Redis backend errors distinguishable.

Add regression coverage for normal, active, concurrent, memory, and Redis
failure cases.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
- broaden Redis workflow path filters
- run Redis tests in the unit workflow
- strengthen packaged Redis smoke coverage
- update pkg CLI options for current packaging support

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Add bounded expiration continuation across Redis shards so document-presence
and force-save queues continue draining across GC passes without unbounded
Redis work.

Preserve parallel force-save processing with isolated per-item contexts,
handle resolved and thrown failures without acknowledging unsuccessful saves,
recover partial shard failures, and expose/log shard failure metadata.

Extend expiration, GC scheduling, retry, lease recovery, cluster-routing,
and concurrent force-save tests.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Prevent replica clock skew from expiring live document presence or
triggering premature document cleanup. Use Redis TIME in presence Lua
scripts and document GC while preserving connection protections, leases,
and Cluster key routing.

Add regression coverage for clock skew, TTL renewal, expiry, GC, and
standalone/Cluster Redis execution.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Retain a leased resolved marker so duplicate acknowledgements succeed without
masking unknown, expired, mismatched, or malformed claims. Preserve claim
recovery and protect newer tasks from stale acknowledgements.

Add Redis and callback regression coverage for duplicate delivery, concurrent
acknowledgements, expiry, malformed claims, and cleanup claim propagation.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Split Redis scripts, data/stat stores, configuration, and connection handling
into focused modules while preserving public APIs and behavior.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Reject Sentinel commands before initial readiness, support CI-specific
Cluster container prefixes, and document local Redis topology test setup.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Preserve active saved-state claims, enforce replica-safe cleanup results, and
gate terminal side effects on confirmed cleanup. Add end-to-end disconnect
coverage and Redis test readiness checks.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Add bounded Redis startup retries, cancellation, schema failure handling, and
readiness-gated HTTP server startup. Cover recovery, exhaustion, shutdown,
overlapping installs, and invalid retry configuration with regression tests.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Document the saved:claim key lifecycle and configured TTLs, and add the
standard SPDX header to the Redis compatibility shim.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Remove redundant Redis option handling and document the @redis/* packages
pulled in by node-redis 6.2.1.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Share Redis test setup across process and topology suites, and reuse
expiration test setup while preserving coverage for both queue types.

Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
@nvanlaerebeke
nvanlaerebeke force-pushed the feature/redis-editor-data-storage branch from 457e63f to f62c462 Compare October 5, 2026 13:27
@nvanlaerebeke

nvanlaerebeke commented Oct 5, 2026 •

Copy link
Copy Markdown
Author

This PR is now in a good state to start splitting up I think.

Since 9050fa9 (f9a2149 after rebase), I have:

  • completed the Redis editor-data lifecycle and shutdown handling
  • hardened connection, reconnect, timeout, failure, and data-loss recovery
  • added support and coverage for standalone Redis, Redis Cluster, and Redis Sentinel
  • added Valkey-compatible topology handling
  • improved presence expiry, document expiry, force-save handling, saved-state recovery, and idempotent acknowledgements
  • split the Redis implementation into independently testable modules
  • added bounded expiration processing, retry-safe claims, leases, and cleanup handling
  • added unit, integration, process, topology, failover, and packaged-install tests
  • expanded the CI workflows and documented the local Redis test setup

I also ran the complete local test suite, including all of the procedures documented in server/tests/redis/README.md for each topology: standalone Redis with and without authentication, Redis Cluster, and Redis Sentinel.

I did not test every Redis/Valkey version from the CI matrix, but the topology coverage was completed locally.
I also ran the E2E tests on a locally built DocumentServer image, those need the changes in [Euro-Office/DocumentServer#386] to run.

I have started splitting up the PR.

The first split is #52, which fixes configuration lifecycle issues found while running the Redis tests, I've rebased this branch on top of that PR.

The next split will extract saved-state claim handling.
I expect that split to be approximately 300–400 lines, should be something that I can easily rebase this branch on as well.

I'll also have to do Euro-Office/DocumentServer#386, but for that I'll need a PR for the helm chart as well.

So related PRs so far:

Most of this PR are tests, so I'll take it one step at a time.

@j-base64

j-base64 commented Oct 6, 2026

Copy link
Copy Markdown

Thanks for the big push here @nvanlaerebeke, awesome work ! that's a lot of ground covered 💪.
We're going to take a proper look now and will come back with anything useful that surfaces.
We'd also like to see whether we can exercise it soon against a substantial real infrastructure setup on our side.
Either way, really appreciate the work done on this 🙌

@MonaAghili MonaAghili 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.

Thanks @nvanlaerebeke, splitting it this way works for us and fits the stack @moodyjmz proposed. I checked the current head f62c4625 with the split in mind. The points below are what the slices will need for green CI.

1. The E2E job can't pass while the overlay keeps the nightly's default.json (reproduced)
The overlay step replaces only the service binaries in the nightly image (e2e.yml#L102-L123). DocService then reads the nightly's default.json, which has no services.CoAuthoring.expire.shard, and gc.js#L44 reads that key with config.get when the module loads. DocService exits with Configuration property "services.CoAuthoring.expire.shard" is not defined, and supervisor restarts it in a loop. This also happens on the default in-memory backend. With editorDataRedis selected, it fails next on savedClaim (editorDataSettings.js#L25). The new smoke step waits for /healthcheck until it times out, and the Playwright steps after it are skipped. The job log doesn't show the error, because docker logs doesn't include DocService's own log file.
Suggested fix: in the overlay, also copy the branch's Common/config/default.json to /etc/euro-office/documentserver/default.json. The image's local.json still sets the database values. With that change, the container becomes healthy and editorDataRedis.pkg-e2e.sh passes. When the health check gives up, also print "$EO_LOG"/docservice/err.log from the container, which is where the error above shows up. Any slice that adds a key to default.json and reads it with config.get will hit this, so it's easiest to fix the overlay first.

2. All 11 Redis/Valkey jobs fail on one test (reproduced)
DocsCoServer.disconnect.handler.tests.js#L13-L35 mocks socket.io as a virtual module. If an earlier file in the same Jest worker has already loaded DocsCoServer with the real socket.io, the mock isn't applied, and the test fails at io.engine.on with Cannot read properties of undefined (reading 'on'). In CI (--runInBand, empty cache), canvasservice.saved.tests.js and DocsCoServer.forceSave.tests.js run earlier and load it without the mock; either one alone is enough to make the test fail. Locally, Jest runs previously failed files first, so a second run passes and hides the problem.
Suggested fix: mock the path DocService actually resolves, without virtual:

jest.mock('../../DocService/node_modules/socket.io', () => ({
  Server: class {
    // same body as now
  }
}));

With only that change, tests/redis passed 196 of 196, twice, with an empty cache. DocsCoServer.startup.tests.js#L24 uses the same virtual socket.io mock and is worth changing at the same time.

@j-base64

j-base64 commented Oct 6, 2026

Copy link
Copy Markdown

Because we're very interested in this work, I ran an independent deep review (at f62c4625) in case anything interesting surfaced worth sharing. Except for the CI points already spotted by @MonaAghili above, found nothing blocking at this stage, just a few smaller correctness points.

Sharing them in case they're useful, before or after the PR splitting.

📑 FULL REPORT (click to expand): 6 minor points, none blocking

CI is currently red at this head, two separate causes

Nothing to add to @MonaAghili's findings above.

Correctness / parity

  • statistics.js:40, ADD_UNIQUE_USER_SCRIPT has no key-level TTL. Every sibling stat key (ADD_MONTH_USER_SCRIPT, the shard counters) gets a PEXPIRE; this one doesn't. A tenant that stops being read (the license route never gets polled for it) keeps these two keys forever, since the only pruning is lazy and happens inside the read path. Worth mirroring ADD_MONTH_USER_SCRIPT and expiring both keys to the member horizon.

  • DocsCoServer.js:2136, a transient Redis error mid-closeDocument skips the rest of disconnect cleanup. The per-connection cleanup steps (removePresenceDocument, element-lock release, the last-editor save trigger) run sequentially under one outer try/catch with no per-step isolation, so a Redis blip on an early step (removePresence/getPresence) aborts everything after it. editorDataMemory's equivalents can't throw, so this is Redis-specific. Bounded by TTL + the gc.js sweep, but worth a look since it only needs to happen on the disconnect path.

  • savedState.js:52, a narrow concurrent final-save race throws instead of returning null. Two final-save callbacks (isSfcm=false) for the same document, reaching consumeSavedState with distinct claim ids while the updateIf DB CAS lets both through (the leftover-UpdateVersion branch), has the second getdelSaved throw SavedStateUnknownError where editorDataMemory would return null. The throw isn't caught around consumeSavedState in canvasservice.js, so it rejects that callback and skips the status-restore/cleanup block. Reachability is narrow, and fail-closed here might be the right call over memory's silent proceed, just flagging the uncaught-throw path in case it isn't deliberate.

The differential suite

The two-process harness (editorDataRedis.process.tests.js, real fork(), memory backend as oracle) is a genuinely good piece of infrastructure, exactly the shape we'd expect for this kind of harness. Two smaller gaps:

  • forceSave.js:51, the command-path reset guard (SET_FORCE_SAVE_SCRIPT) refuses to reset while a conversion is active, but editorDataMemory.checkAndSetForceSave still does the unconditional reset. That's arguably the right divergence (it avoids a double-conversion race), but the differential suite doesn't exercise this input against both backends, so the oracle goes silent exactly where they diverge. Worth either mirroring the guard in memory, or adding a comment/test noting the deliberate exception.

  • editorDataRedis.failure.integration.tests.js:13, the real-connection failure suite is gated by TEST_REDIS_FAILURE_CONTAINER ? describe : describe.skip with no "refuse to run without it" guard, unlike editorDataRedis.topology.tests.js's throwing guard. If that env var is ever dropped or misspelled in the standalone CI step, this file silently registers zero tests and the step still exits green.

  • gc.js:84, one failing shard (out of 16) throttles the whole expired-queue drain back to the normal interval, even though the other 15 shards came back with full batches. Not a stall (bounded by TTL and the next GC interval, roughly 1-2 min by default), but worth knowing the single-shard blast radius is now "delay" rather than "stop", in case a tighter follow-up is wanted under partial Cluster degradation.

There are also a few smaller nits I kept out to keep this focused; happy to share them separately if they'd be useful.

Thanks again for all the work here, keen to see the split land.

Review assisted by Claude/Opus-4-8

nvanlaerebeke added a commit to nvanlaerebeke/DocumentServer that referenced this pull request Oct 8, 2026
Update the orchestrated entrypoint to emit the server’s
optionsSentinel structure instead of the legacy iooptions format.
Also parse Sentinel and Cluster node lists as comma-separated values.

Refs: Euro-Office/server#46
Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
nvanlaerebeke added a commit to nvanlaerebeke/DocumentServer that referenced this pull request Oct 8, 2026
Document standalone, Sentinel, Cluster, and Valkey setup and verification.
Clarify published-image defaults and local-image validation.
Update AGENTS.md with the new workflow guidance.

Refs: Euro-Office/server#46
Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
nvanlaerebeke added a commit to nvanlaerebeke/DocumentServer that referenced this pull request Oct 11, 2026
Update the orchestrated entrypoint to emit the server’s
optionsSentinel structure instead of the legacy iooptions format.
Also parse Sentinel and Cluster node lists as comma-separated values.

Refs: Euro-Office/server#46
Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
nvanlaerebeke added a commit to nvanlaerebeke/DocumentServer that referenced this pull request Oct 11, 2026
Document standalone, Sentinel, Cluster, and Valkey setup and verification.
Clarify published-image defaults and local-image validation.
Update AGENTS.md with the new workflow guidance.

Refs: Euro-Office/server#46
Assisted-by: Codex:GPT-5
Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
@nvanlaerebeke

Copy link
Copy Markdown
Author

Thanks for the big push here @nvanlaerebeke, awesome work ! that's a lot of ground covered 💪. We're going to take a proper look now and will come back with anything useful that surfaces. We'd also like to see whether we can exercise it soon against a substantial real infrastructure setup on our side. Either way, really appreciate the work done on this 🙌

Thanks! @j-base64, if you'd like to test this, the easiest way would be to deploy it using the Helm chart.

Here's the setup I've been using for testing:

This combination has been working well for my tests so far and should provide a good starting point for testing it in your own infrastructure.

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants