feat: add Redis-backed editor data storage for high availability - #46
nvanlaerebeke wants to merge 42 commits into
Conversation
|
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. |
|
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 |
|
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. |
|
@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) |
|
@nvanlaerebeke @MonaAghili — comparison of #46 against #44, read at ConvergentFour non-obvious
A clean-room derivation from DivergentSurface. #46 implements the full surface including #44's partial state is not merely incomplete, it is harmful: Test infrastructure. #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 Findings1. Both cross-document indexes are on one slot. 2. The command timeout outlives the transport. 3. Sentinel deployments run standalone silently. The orchestrated entrypoint emits sentinel configuration only under The reverse applies to #44: the standalone entrypoint writes credentials only under 4. 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 ioredis's 5. Client librarynode-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
Cluster options belong in 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. 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: 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 Cluster readiness has no client-side signal: Config surface. #46 removes the Open, not delivered
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. |
|
Independent deep-dive on #46 against Where this overlaps with the comment above, I've marked it; the rest is new. SummaryThe hard behavioral work is largely right: lock re-entrancy, the numeric unlock enum, But five separate things block merge, three of which aren't in the discussion yet: Blockers1. Issue #267 itself is not demonstrably fixed. The new module is split into six files under 2. // editorStat.js:228-230
EditorStat.prototype.deleteKey = async function (key) {
await this._command(['DEL', key]);
};
3. Force-save Lua scripts This round-trips the entire 4. Sentinel support is silently inert. The orchestrated DocumentServer entrypoint writes sentinel settings under 5.
Also worth flagging (not blocking, but real)
Test gaps
Bottom lineCompetent and the most complete of the candidates — full |
|
@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 |
|
@nvanlaerebeke @MonaAghili — both reviews verified against If you are working on this tonightYou 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. 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:
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 If you only get to a few things: TL;DR
Corrections — my earlier comment
Unchanged: slot 1073 (re-confirmed by Corrections — the review aboveB1, #267's stack fails in 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 (A bare W4, dependency growth — inverted. 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, B2, B5a, W6, installed 4.7.0 — local to that checkout; the shrinkwrap pins 6.2.1 and the workflow runs Confirmed as stated: B5b Contradictions between the two reviews
Remaining workDefects:
Not demonstrated rather than broken: #267 end-to-end (boot a packaged DocService with Client librarynode-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 ( Open: fail-open versus fail-closed
A — throw from every method (this PR today). Uniform, no call-site changes, and safe where a plausible-looking value is dangerous: 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 — C — per-method. Most work, and the answer space is three-valued: deny (lock methods); throw or return a distinct sentinel ( #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. |
|
@j-base64 just to add you here |
|
CI results on
The cluster failure is the harness, not the code. The nodes start correctly via Add 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 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 |
|
Merged Re-approved the checks against the new head. |
1f9f803 to
913378a
Compare
Assisted-by: OpenAI Codex Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
…rage implementation Assisted-by: OpenAI Codex Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Assisted-by: OpenAI Codex Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Align the header with the rest of the codebase, which is AGPL version 3 only and attributes copyright to Euro-Office contributors. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Julius Knorr <jus@bitgrid.net>
913378a to
ee22902
Compare
Assisted-by: OpenAI Codex Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
|
I suggest using the following configuration structure to support:
The structure follows the current {
"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.
Topology selectionThe current implementation selects the topology as follows:
Configuration details
Legacy configuration
The older/intermediate fields The default configuration is defined in The normalization logic is implemented in |
|
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. 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. |
|
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:
Keen to see where this goes 😊 |
|
Thank you everyone for the positive feedback and for taking the time to review the implementation in such detail. 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 What would be the preferred way to handle this?
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. |
Use the redis-7000 container for Redis Cluster readiness checks and cluster creation, avoiding a host-side redis-cli dependency. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Bound POP_EXPIRED batches and lease claimed entries until the GC worker acknowledges successful processing. Unacknowledged claims are reclaimed after the lease expires, preventing lost Redis responses or worker crashes from dropping document-presence and force-save work. Keep all claim keys in the existing Redis Cluster hash slot and add regression coverage for batching, timeouts, lost responses, partial processing, and stale acknowledgements. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Keep force-save payloads unchanged while Redis Lua updates only the small state fields. This prevents empty arrays becoming objects, large numbers being rounded, and convertInfo being cleared unexpectedly. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Replace the yes pipeline with redis-cli supported --cluster-yes option. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Build and run the packaged DocService binary with the Redis editor-data backend, verifying module resolution, health, and cleanup in CI. Also extract shared Redis test helpers for context creation, delays, and public method discovery. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Apply Prettier formatting fixes required by the server format check. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Share compatible Redis clients through an explicit connection manager, preserve isolation for separate databases and topologies, and ensure connections drain and close exactly once during shutdown. As a consequence of the ownership refactor, clean up the Redis storage implementation by splitting configuration, transport, lifecycle, codecs, keys, settings, and EditorCommon responsibilities into focused modules. Remove the obsolete base.js façade and update callers and tests. Add lifecycle, module-boundary, and shutdown coverage. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
|
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:
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 |
Validate the connected client reference before dispatching Redis commands so concurrent aborts return RedisUnavailableError instead of TypeError. Add deterministic regression coverage for the connection-abort race. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Add authenticated and unauthenticated Redis configuration tests, failover integration coverage for Cluster and Sentinel, and failure-policy tests for connection loss during document cleanup. Make dedicated topology CI jobs fail when their required failover configuration is missing, and ensure unauthenticated tests remove inherited credentials. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Replace unsafe Redis GETDEL handling with durable claim/read/ack semantics. Preserve claims through successful cleanup, recover abandoned claims during terminal document cleanup, and fail closed on unknown Redis outcomes. Add focused tests for retries, concurrency, cleanup races, lost responses, and canvasservice failure handling. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Default optionsSentinel to an empty object when absent for compatibility with older DocumentServer configurations. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
|
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 Fix: keep Details
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();
}
});
|
|
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:
Open questions:
Details
|
|
I re-checked Not raised yet1. Regression on the default in-memory backend: every final save is treated as failed (reproduced) 2. SIGTERM, SIGINT and 3. Sentinel mode runs commands one at a time (reproduced) 4. Lost presence entries are never rebuilt, so live editors can vanish
The next editor who leaves then triggers the final save and 5. The Helm chart can't configure Sentinel for this PR 6. Two additions to @moodyjmz's startup-latch point
Should also be fixed (medium)
Low / cleanup
|
|
Thanks @MonaAghili. Three additions, and a question: #4 presence: reproduced. Against node-redis 6.2.1 and a real redis-server at The latch feeds #4. A latched replica's heartbeats fail, so after #2: unhandled rejections take the same path. On the stack proposal: your findings map onto it. Sentinel (#3, #5) goes in the connection core, presence (#4) in the presence slice, shutdown (#2) in the top slice, and the saved-state regression (#1) in the separate PR that lands first. @MonaAghili, does that cut work for how you'd review this and for the work on your side? @nvanlaerebeke, you know the code best: would you split it this way, or draw the lines differently? |
|
Thanks @moodyjmz. Both additions check out on my side: the presence scripts are identical between The stack works for me, your placement of items 1 to 5 matches how I'd review it, and I'm happy to go slice by slice. For the rest of my list:
Item 5 also needs a companion Kubernetes-Docs change. Each slice should extend the Redis workflow's path filter to the files it touches, and |
|
@nvanlaerebeke @moodyjmz @MonaAghili, regarding the "Packaging check" point above: The end goal would be that the build fails when a pkg.scripts entry matches no file. That is exactly what confused us here: the module was missing but the build never warned us, because an unmatched pkg.scripts glob passes silently. Does that sound right to you? |
|
Thanks for the Redis test suite; all 124 existing tests pass against the sketch below unchanged. Suggestion: restructure #46 along logical boundaries, so each piece can be reviewed and reused on its own. Much of it sits in a few large units: presence, locks, saved state, force-save and the expiry queues all live on DetailsWorked example: class LockStore {
// ...
constructor(ops, docBase) {
this.ops = ops; // {eval, command, transaction}
this.docBase = docBase;
}
lockSave(ctx, docId, userId, ttl) {
return this.#lock(this.#key(ctx, docId, 'savelock'), userId, ttl);
}
async addLocks(ctx, docId, locks) {
const fields = argsFromObject(locks);
if (fields.length === 0) return;
const key = this.#key(ctx, docId, 'locks');
await this.ops.transaction(key, [['HSET', key, ...fields], ['EXPIRE', key, ttlSeconds(ctx, LOCKS_TTL_PATH, cfgExpLocks)]]);
}
async addLocksNX(ctx, docId, locks) {
// ...HSETNX per field, EXPIRE, HGETALL in one routed MULTI;
// a field whose HSETNX returned 0 is a conflict.
}
async removeLocks(ctx, docId, locks) {
const lockIds = Object.keys(locks);
if (lockIds.length > 0) await this.ops.command(['HDEL', this.#key(ctx, docId, 'locks'), ...lockIds]);
}
#key(ctx, docId, name) {
return `${this.docBase(ctx, docId)}${name}`;
}
async #lock(key, fencingToken, ttl) {
// unchanged LOCK_SCRIPT compare-and-set, fail-closed on error
}
// ...
}
Difference found by the behaviour test: the memory backend's Reusable pieces:
Units with one job each:
How they map onto the proposed stack:
Lua that could be plain commands. Ten of the 27 scripts are unconditional batches of writes, or map onto an existing command ( The 17 that need Lua (compare-and-set, read-modify-write, prune-then-read) could be registered with the client via Syntax. ES classes with Repro: sketch applied on |
|
@j-base64 sounds right as its own issue; pkg does drop unmatched patterns silently, so a check that fails on an entry matching nothing would have caught it. |
|
@moodyjmz, that sounds like a good idea. Splitting this PR into multiple smaller units should make it easier to review, it started out much smaller and more localized that it is now. I’d like to finish a few remaining open items and address the points raised by @MonaAghili before cleaning up the code and splitting it up a bit further. Once that is done and it's in a state I'm comfortable with, I’ll split the implementation into smaller, focused PRs. Your suggested boundaries make sense and I’ll work toward that. |
Distribute presence-expiry and force-save indexes across 16 deterministic shards derived from tenant and document ID, while keeping all document-local index operations single-slot routable. Fan out expiration claims across shards with a bounded aggregate limit of 96 entries per queue per GC pass, preserving lease, acknowledgement, retry, and response-loss recovery behavior. Centralize index key construction, update Redis documentation, and add standalone/Cluster coverage for shard distribution, key slots, expiration, force-save processing, and batch limits. No migration is included because Redis support is not currently deployed. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Prevent a timed-out Redis command from poisoning replacement clients or unrelated Redis-backed operations. - separate editor data and statistics connection groups - preserve transport aborts and fail-closed coordination behavior - allow transient connection failures to reconnect - guard aborts against stale client generations - add lifecycle, concurrency, notification, and topology coverage - require Sentinel topology settings in failover CI - prevent Redis tests from accumulating log listeners Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Generate canonical optionsSentinel configuration for standalone and orchestrated deployments, preserving standalone and Cluster behavior. Validate Sentinel topology input strictly and keep Redis and Sentinel credentials separate. Add entrypoint coverage for all Redis topologies and reduce duplication in the server Redis connection and expiration handling. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Enable Redis offline-queue disabling across supported topologies, fail fast when clients are reconnecting, and expand standalone, Cluster, and Sentinel failure/recovery coverage. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
There was a problem hiding this comment.
I re-checked 9050fa9 against the discussion above. The first section lists only what hasn't been raised yet; the second is the status of earlier items at this head. Items marked reproduced were run against Redis 7 with this PR's own modules and node-redis 6.2.1 (the pinned version); the rest were traced in the code.
Not raised yet
1. CI is red at the head (reproduced)
The fork's run for 9050fa9 fails all 16 Redis/Valkey jobs and the Prettier check.
- All 16 jobs:
editorDataRedis.edge.tests.jsasserts that the global force-save queue is empty (edge.tests.js#L56), buteditorDataRedis.presence.tests.jsleaves a timer behind (presence.tests.js#L19). Every job ran presence before edge, and the edge test received[["presence-index-shard-write","document"]]. Locally, edge passes on a fresh prefix and fails the same way after presence. - The 5 Sentinel jobs: these also fail
rejects commands issued before initial readiness for every configured topologyineditorDataRedis.topology.tests.js. With this PR's Sentinel options, a command sent before readiness is still queued after the test's 2 s bound; standalone rejects it in about 130 ms. This reproduces locally, so the fail-fast guarantee the test checks doesn't hold for Sentinel. - Prettier: it flags
editorDataRedis.connection.tests.js,editorDataRedis.expiration.tests.js,editorDataRedis.failure.integration.tests.jsandeditorDataRedis.topology.tests.js. ESLint passes.
Suggested fix: clean up the timer in the presence test and have the edge test check its own document instead of the global queue; make pre-ready Sentinel commands fail fast.
2. One failing shard stops expiry and auto-save for every document (reproduced, new in 9b39145)
_popExpiredAcrossShards() pops the 16 shards with Promise.all (editorData.js#L197-L209). If one shard's EVAL rejects, for example while a Cluster master is down, the whole GC pass fails. The members the other 15 shards already moved into their lease are dropped, and they stay invisible until the 5-minute lease expires. With one shard failing, 90 members were stuck. Before this commit the index used a single {editor:index} tag (slot 1073), so GC depended on one master. It now depends on every master that owns one of the 16 shard slots, which in a default three-master cluster is all three.
Suggested fix: Promise.allSettled, returning the shards that succeeded and logging the failures.
3. A live viewer is enough to reach the cleanup orphan path (reproduced)
Earlier in the thread, the orphan path was put down to index-write ordering or clock skew. A viewer alone is enough:
- The last editor leaves while a viewer is still connected.
hasEditors()ignores viewers, so the save or no-changes cleanup runs, andCLEAN_DOCUMENT_SCRIPTdeletes nothing because the viewer's presence remains (scripts.js#L376-L401). - When the viewer leaves,
closeDocument()takes the view branch, which never callscleanDocumentOnExit()(DocsCoServer.js#L2189-L2192).removePresenceDocument()also drops the documents index entry, so GC never returns to the document. - The next session on the same key gets the previous session's chat in its auth response (DocsCoServer.js#L3402) for up to 24 h; the in-memory backend clears it.
- The force-save record stays for up to 7 days. With
autoAssemblyon, the leftover force-save timer later starts a conversion for a document that has already been saved.
I reproduced this by replaying closeDocument()'s storage calls in order.
Suggested fix: keep the HLEN guard, but run the deferred cleanup when the last presence entry goes, for example in the view branch or in removePresenceDocument().
4. A saved-state claim can leak without a crash (adds to the abandoned-claim item)
Suppose the integrator sent c=saved with a status other than '1', and the save was encrypted or ended with isError set: a non-corrupted conversion error, or no file URL or users. The callback is still sent; if the integrator answers {error: 0}, the claim is taken. The condition at canvasservice.js#L1324 then skips the whole block containing both the cleanup and ackSaved(). The claim stays with no TTL, and unless a no-changes cleanup clears it first, the next final save of that document throws SavedStateUnknownError. The path is traced; the stuck claim and the throw are reproduced.
Low
- Capacity: one GC pass claims at most 96 entries per queue per instance, and nothing loops to drain the rest (reproduced: 96 of 500). With
autoAssemblyon (5-minute interval, 1-minute step), about 480 actively edited documents per instance is enough to grow the auto-save backlog without bound. - Clock skew: presence expiry uses each node's own
Date.now(), andGET_PRESENCE_SCRIPTdeletes expired entries on read. Since presence is never rebuilt (item 4 in my earlier comment), a node whose clock runs about 3 minutes ahead removes other nodes' live users for good. Traced only. Using RedisTIMEinside the scripts would avoid it. ackSaved()treats an already-resolved claim as an error (editorData.js#L318-L327). If the same task is delivered twice (samesaveKey, so the same claim id), the second ack throws andcommandSfcCallback()ends with an error before itsupdateVersionpublish andremoveShutdown()(reproduced). The first delivery already ran both, so this is noise rather than a stuck state; logging would be enough.
Status of earlier items at 9050fa9
None of savedState.js, server.js, canvasservice.js, gc.js, DocsCoServer.js or editorDataMemory.js changed after 85c8fb3.
Still open:
- The in-memory backend treats every final save as failed (re-reproduced:
{success: false, claimed: true}where the old check gavetrue). - SIGTERM, SIGINT,
uncaughtExceptionand unhandled rejections hang while editors are connected. Re-checked:server.close()never calls back while an upgraded socket is open, and on Node 20 an unhandled rejection reaches theuncaughtExceptionhandler. - Lost presence entries are never rebuilt (re-reproduced after
FLUSHALL: presence 0, the index entry gone, and the remaining editor's locks deleted at cleanup). - The saved-state claim has no TTL, while
receiveTaskacks infinally(claim TTL -1; the next save throws). - Sentinel runs commands one at a time:
reserveClientis still not set, and node-redis 6.2.1 still defaults tomasterPoolSize: 1. maxCommandRediscovers: 0also limits Sentinel topology discovery: the node-redis#connect()loop throws on its first failure.- The entrypoint writes
iooptionswhile the PR readsoptionsSentinel, and the PR rejects theioredisconnector name (DocumentServermainunchanged, #386 still open). documentsCronvs the presence and shard TTLs, and GC per-item error handling.- The force-save guard returns
UnknownErrorfor Form/Internal requests (re-reproduced). - The CI path filter still misses the caller files, and the unit workflow still runs only
jest unit. - The README omits
saved:claim,editorStat.jsstill ends with the Ascensio block, and theeditorDataRedis.jsshim has no SPDX header.
Fixed:
- A failed connect no longer disables Redis for the life of the process (
b2c1b80). editorDataandeditorStatnow have separate clients. Checked: a timed-out editor-data command no longer affects editor-stat, but it still fails every other in-flight editor-data command.- The README's GC rate wording.
The PR description says Prettier and the Redis suite pass, which no longer holds at this head; ESLint does pass.
|
Thank you @MonaAghili for the summary, it'll make it much easier for me to work starting from that. I can do only a couple of things per evening, I'll try to get those addressed asap. |
|
Opened #48 with the packaging check you suggested tracking separately from this PR.
|
Clean up presence and force-save test state, scope queue assertions to the test document, and fail Sentinel commands fast before initial readiness. Format the affected Redis tests. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Return null for absent saved state to match the Redis contract and prevent successful normal final saves from being reported as failures. Add callback and editor-data regression coverage. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
Await Socket.IO cleanup before Redis teardown, terminate upgraded connections during shutdown, clean runtime watchers and timers, and add regression coverage for signals, fatal errors, reconnects, and timeout fallback. Assisted-by: Codex:GPT-5 Signed-off-by: Nico Van Laerebeke <1520618+nvanlaerebeke@users.noreply.github.com>
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>
Summary
This pull request adds Redis-backed storage for the Euro Office Co-Authoring service to support high-availability deployments.
The shared Redis state allows multiple server instances to participate in the same editing session. It covers the coordination state required by Co-Authoring, including:
The backend supports both standalone Redis and Redis Cluster. Redis is used for shared state and does not replace the existing RabbitMQ pub/sub or task queue mechanisms.
Redis storage is not enabled by default. It can be selected with:
{ "services": { "CoAuthoring": { "server": { "editorDataStorage": "editorDataRedis" } } } }Testing
The implementation has been tested with Redis and is working as expected.
The test coverage includes Co-Authoring presence, locking, messaging, force-save behavior, statistics, expiration, timeout recovery, health checks, and loading the backend through the packaged configuration.
Local validation completed successfully:
git diff --checkpassed.Implementation note
The initial implementation was largely AI-generated. It has been reviewed, cleaned up for this pull request, and re-tested; everything currently appears to be working as expected.
This is submitted as a candidate implementation for review. If the maintainers do not want to merge it in its current form, it may still serve as a useful working base for implementing Redis-backed high availability in the future.