Files
upstream-hocuspocus/tests/extension-redis/publishCoalescing.ts
upstream-mirror e0c588ca1c
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
2026-09-15 17:21:35 +05:00

206 lines
5.9 KiB
TypeScript

import { Redis } from "@hocuspocus/extension-redis";
import { Document, IncomingMessage, MessageType } from "@hocuspocus/server";
import test from "ava";
import {
newHocuspocus,
newHocuspocusProvider,
redisConnectionSettings,
sleep,
} from "../utils/index.ts";
import { retryableAssertion } from "../utils/retryableAssertion.ts";
/**
* Counts what actually reaches Redis by wrapping the extension's publisher.
*/
const countingRedis = () => {
const extension = new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
});
const publisher = (extension as any).pub;
const original = publisher.publish.bind(publisher);
let published = 0;
publisher.publish = (...args: any[]) => {
published += 1;
return original(...args);
};
return { extension, publishCount: () => published, reset: () => (published = 0) };
};
test("coalesces a burst of changes into a single publish", async (t) => {
const redis = countingRedis();
const server = await newHocuspocus(t, { extensions: [redis.extension] });
const provider = newHocuspocusProvider(t, server, {
name: "redis-coalescing",
awareness: null,
});
await new Promise((resolve) => provider.on("synced", () => resolve("done")));
await sleep(300);
redis.reset();
// Drive the burst on the server's own document. Twenty separate transactions
// applied synchronously are guaranteed to share one event loop turn; sending
// them through the provider would leave frame delivery, and therefore the
// turn boundaries, outside the test's control.
const document = server.documents.get("redis-coalescing");
t.truthy(document);
const map = document!.getMap("test");
for (let i = 0; i < 20; i += 1) {
map.set(`key-${i}`, i);
}
await sleep(300);
// Without coalescing this is one SyncStep1 publish per change, so 20.
t.is(redis.publishCount(), 1);
});
test("a failing sync publish does not swallow the awareness publish", async (t) => {
const extension = new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
});
const server = await newHocuspocus(t, { extensions: [extension] });
const provider = newHocuspocusProvider(t, server, {
name: "redis-publish-failure",
});
await new Promise((resolve) => provider.on("synced", () => resolve("done")));
await sleep(300);
// Reject the sync step specifically, by message type rather than by call
// order, and let everything else publish normally.
const publisher = (extension as any).pub;
const original = publisher.publish.bind(publisher);
const attempted: number[] = [];
const succeeded: number[] = [];
publisher.publish = (key: string, payload: Buffer) => {
const [, messageBuffer] = (extension as any).decodeMessage(payload);
const message = new IncomingMessage(messageBuffer);
message.readVarString(); // document name
const messageType = message.readVarUint();
attempted.push(messageType);
if (messageType === MessageType.Sync) {
return Promise.reject(new Error("redis is down"));
}
succeeded.push(messageType);
return original(key, payload);
};
// Both applied server-side and synchronously, so they share one turn and
// therefore one pending entry — which is the case under test.
const document = server.documents.get("redis-publish-failure");
t.truthy(document);
document!.getMap("test").set("a", 1);
document!.awareness.setLocalState({ user: "jan" });
await sleep(300);
t.true(attempted.includes(MessageType.Sync), "the sync step was attempted");
t.true(
succeeded.includes(MessageType.Awareness),
"the awareness update was published despite the sync step failing",
);
});
test("a reloaded document does not inherit the previous instance's pending publish", async (t) => {
const extension = new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
});
const server = await newHocuspocus(t, { extensions: [extension] });
const provider = newHocuspocusProvider(t, server, {
name: "redis-reload",
awareness: null,
});
await new Promise((resolve) => provider.on("synced", () => resolve("done")));
const first = server.documents.get("redis-reload");
t.truthy(first);
// A fast reconnect can replace the Document under an unchanged name while a
// publish is still buffered, because `afterUnloadDocument` returns early and
// never clears it. Buffering against the old instance and then scheduling
// against a new one must not carry the stale document forward.
const schedule = (extension as any).schedulePublish.bind(extension);
const pending = (extension as any).pendingPublishes;
schedule("redis-reload", first);
t.is(pending.get("redis-reload").document, first);
const reloaded = new Document("redis-reload");
t.is(schedule("redis-reload", reloaded).document, reloaded);
t.is(pending.get("redis-reload").document, reloaded);
reloaded.destroy();
});
test("a coalesced burst still reaches another instance", async (t) => {
const server = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `server${crypto.randomUUID()}`,
}),
],
});
const anotherServer = await newHocuspocus(t, {
extensions: [
new Redis({
...redisConnectionSettings,
identifier: `anotherServer${crypto.randomUUID()}`,
}),
],
});
const provider = newHocuspocusProvider(t, server, {
name: "redis-coalescing-sync",
awareness: null,
});
const anotherProvider = newHocuspocusProvider(t, anotherServer, {
name: "redis-coalescing-sync",
awareness: null,
});
await new Promise((resolve) =>
anotherProvider.on("synced", () => resolve("done")),
);
const map = provider.document.getMap("test");
for (let i = 0; i < 20; i += 1) {
map.set(`key-${i}`, i);
}
// Every change of the collapsed burst has to arrive, not just the last one.
await retryableAssertion(t, (tt) => {
const received = anotherProvider.document.getMap("test");
for (let i = 0; i < 20; i += 1) {
tt.is(received.get(`key-${i}`), i);
}
});
});