snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
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
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
This commit is contained in:
106
tests/extension-redis/onAwarenessChange.ts
Normal file
106
tests/extension-redis/onAwarenessChange.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Redis } from "@hocuspocus/extension-redis";
|
||||
import type { onAwarenessChangeParameters } from "@hocuspocus/provider";
|
||||
import test from "ava";
|
||||
import {
|
||||
newHocuspocus,
|
||||
newHocuspocusProvider,
|
||||
redisConnectionSettings,
|
||||
} from "../utils/index.ts";
|
||||
|
||||
test("syncs existing awareness state", async (t) => {
|
||||
const documentName = `test-${crypto.randomUUID()}`;
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
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: documentName,
|
||||
onSynced() {
|
||||
// Once we're set up, change the local Awareness state.
|
||||
// The updated state then needs to go through Redis:
|
||||
// provider -> server -> Redis -> anotherServer -> anotherProvider
|
||||
provider.setAwarenessField("name", "first");
|
||||
|
||||
// Time to initialize a second provider, and connect to `anotherServer`
|
||||
// to check whether existing Awareness states are synced through Redis.
|
||||
newHocuspocusProvider(t, anotherServer, {
|
||||
name: documentName,
|
||||
onAwarenessChange({ states }: onAwarenessChangeParameters) {
|
||||
// Wait until we have exactly 2 states with the expected data
|
||||
const state = states.find(
|
||||
(state) => state.clientId === provider.document.clientID,
|
||||
);
|
||||
if (states.length === 2 && state?.name === "first") {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("syncs awareness between servers and clients", async (t) => {
|
||||
const documentName = `test-${crypto.randomUUID()}`;
|
||||
|
||||
await new Promise(async (resolve) => {
|
||||
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, anotherServer, {
|
||||
name: documentName,
|
||||
onSynced() {
|
||||
// once we're setup change awareness on provider, to get to client it will
|
||||
// need to pass through the pubsub extension:
|
||||
// provider -> anotherServer -> pubsub -> server -> client
|
||||
provider.setAwarenessField("name", "second");
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, server, {
|
||||
name: documentName,
|
||||
onAwarenessChange: ({ states }: onAwarenessChangeParameters) => {
|
||||
// Wait until we have exactly 2 states with the expected data
|
||||
const state = states.find(
|
||||
(state) => state.clientId === provider.document.clientID,
|
||||
);
|
||||
if (states.length === 2 && state?.name === "second") {
|
||||
t.pass();
|
||||
resolve("done");
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
60
tests/extension-redis/onChange.ts
Normal file
60
tests/extension-redis/onChange.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Redis } from "@hocuspocus/extension-redis";
|
||||
import test from "ava";
|
||||
import {
|
||||
newHocuspocus,
|
||||
newHocuspocusProvider,
|
||||
redisConnectionSettings,
|
||||
} from "../utils/index.ts";
|
||||
|
||||
test("syncs updates between servers and clients", async (t) => {
|
||||
await new Promise(async (resolve) => {
|
||||
let resolved = false;
|
||||
|
||||
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()}`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Once we’re setup make an edit on anotherProvider. To get to the provider it will need
|
||||
// to pass through Redis:
|
||||
// provider -> server -> Redis -> anotherServer -> anotherProvider
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
provider.document.getArray("foo").insert(0, ["bar"]);
|
||||
},
|
||||
});
|
||||
|
||||
// Once the initial data is synced, wait for an additional update to check
|
||||
// if both documents have the same content.
|
||||
const anotherProvider = newHocuspocusProvider(t, anotherServer, {
|
||||
onSynced() {
|
||||
provider.on("message", () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
|
||||
setTimeout(() => {
|
||||
t.is(
|
||||
provider.document.getArray("foo").get(0),
|
||||
anotherProvider.document.getArray("foo").get(0),
|
||||
);
|
||||
|
||||
resolve("done");
|
||||
}, 200);
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
232
tests/extension-redis/onStateless.ts
Normal file
232
tests/extension-redis/onStateless.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Redis } from "@hocuspocus/extension-redis";
|
||||
import test from "ava";
|
||||
import {
|
||||
newHocuspocus,
|
||||
newHocuspocusProvider,
|
||||
redisConnectionSettings,
|
||||
} from "../utils/index.ts";
|
||||
|
||||
test("syncs broadcast stateless message between servers and clients", async (t) => {
|
||||
const redisPrefix = crypto.randomUUID();
|
||||
const payloadToSend = "STATELESS-MESSAGE";
|
||||
let resolvePayload!: (payload: string) => void;
|
||||
const payloadReceived = new Promise<string>((resolve) => {
|
||||
resolvePayload = resolve;
|
||||
});
|
||||
let resolveRemoteSynced!: () => void;
|
||||
const remoteSynced = new Promise<void>((resolve) => {
|
||||
resolveRemoteSynced = resolve;
|
||||
});
|
||||
let resolveSenderSynced!: () => void;
|
||||
const senderSynced = new Promise<void>((resolve) => {
|
||||
resolveSenderSynced = resolve;
|
||||
});
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new Redis({
|
||||
...redisConnectionSettings,
|
||||
identifier: `server${crypto.randomUUID()}`,
|
||||
prefix: redisPrefix,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const anotherServer = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new Redis({
|
||||
...redisConnectionSettings,
|
||||
identifier: `anotherServer${crypto.randomUUID()}`,
|
||||
prefix: redisPrefix,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, anotherServer, {
|
||||
onStateless({ payload }) {
|
||||
resolvePayload(payload);
|
||||
},
|
||||
onSynced() {
|
||||
resolveRemoteSynced();
|
||||
},
|
||||
});
|
||||
newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
resolveSenderSynced();
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([remoteSynced, senderSynced]);
|
||||
const document = server.documents.get("hocuspocus-test");
|
||||
if (!document) {
|
||||
throw new Error("Expected the document to be loaded");
|
||||
}
|
||||
document.broadcastStateless(payloadToSend);
|
||||
|
||||
t.is(await payloadReceived, payloadToSend);
|
||||
});
|
||||
|
||||
test("client stateless messages shouldnt propagate to other server", async (t) => {
|
||||
const redisPrefix = crypto.randomUUID();
|
||||
const payloadToSend = "STATELESS-MESSAGE";
|
||||
const barrier = "redis-barrier";
|
||||
const remotePayloads: string[] = [];
|
||||
let resolveLocalPayload!: (payload: string) => void;
|
||||
const localPayloadReceived = new Promise<string>((resolve) => {
|
||||
resolveLocalPayload = resolve;
|
||||
});
|
||||
let resolveRemoteSynced!: () => void;
|
||||
const remoteSynced = new Promise<void>((resolve) => {
|
||||
resolveRemoteSynced = resolve;
|
||||
});
|
||||
let resolveRemoteBarrier!: () => void;
|
||||
const remoteBarrierReceived = new Promise<void>((resolve) => {
|
||||
resolveRemoteBarrier = resolve;
|
||||
});
|
||||
let resolveSenderSynced!: () => void;
|
||||
const senderSynced = new Promise<void>((resolve) => {
|
||||
resolveSenderSynced = resolve;
|
||||
});
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new Redis({
|
||||
...redisConnectionSettings,
|
||||
identifier: `server${crypto.randomUUID()}`,
|
||||
prefix: redisPrefix,
|
||||
}),
|
||||
],
|
||||
onStateless({ payload }) {
|
||||
resolveLocalPayload(payload);
|
||||
},
|
||||
});
|
||||
|
||||
const anotherServer = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new Redis({
|
||||
...redisConnectionSettings,
|
||||
identifier: `anotherServer${crypto.randomUUID()}`,
|
||||
prefix: redisPrefix,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, anotherServer, {
|
||||
onSynced() {
|
||||
resolveRemoteSynced();
|
||||
},
|
||||
onStateless({ payload }) {
|
||||
remotePayloads.push(payload);
|
||||
if (payload === barrier) {
|
||||
resolveRemoteBarrier();
|
||||
}
|
||||
},
|
||||
});
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
resolveSenderSynced();
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([remoteSynced, senderSynced]);
|
||||
provider.sendStateless(payloadToSend);
|
||||
t.is(await localPayloadReceived, payloadToSend);
|
||||
|
||||
const document = server.documents.get("hocuspocus-test");
|
||||
if (!document) {
|
||||
throw new Error("Expected the document to be loaded");
|
||||
}
|
||||
document.broadcastStateless(barrier);
|
||||
await remoteBarrierReceived;
|
||||
|
||||
t.deepEqual(remotePayloads, [barrier]);
|
||||
});
|
||||
|
||||
test("server client stateless messages shouldnt propagate to other client", async (t) => {
|
||||
const redisPrefix = crypto.randomUUID();
|
||||
const response = "test123";
|
||||
const barrier = "redis-barrier";
|
||||
const remotePayloads: string[] = [];
|
||||
|
||||
let resolveResponse: () => void;
|
||||
const responseReceived = new Promise<void>((resolve) => {
|
||||
resolveResponse = resolve;
|
||||
});
|
||||
let resolveRemoteSynced: () => void;
|
||||
const remoteSynced = new Promise<void>((resolve) => {
|
||||
resolveRemoteSynced = resolve;
|
||||
});
|
||||
let resolveRemoteBarrier: () => void;
|
||||
const remoteBarrierReceived = new Promise<void>((resolve) => {
|
||||
resolveRemoteBarrier = resolve;
|
||||
});
|
||||
let resolveSenderSynced: () => void;
|
||||
const senderSynced = new Promise<void>((resolve) => {
|
||||
resolveSenderSynced = resolve;
|
||||
});
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new Redis({
|
||||
...redisConnectionSettings,
|
||||
identifier: `server${crypto.randomUUID()}`,
|
||||
prefix: redisPrefix,
|
||||
}),
|
||||
],
|
||||
async onStateless({ connection }) {
|
||||
connection.sendStateless(response);
|
||||
},
|
||||
});
|
||||
|
||||
const anotherServer = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new Redis({
|
||||
...redisConnectionSettings,
|
||||
identifier: `anotherServer${crypto.randomUUID()}`,
|
||||
prefix: redisPrefix,
|
||||
}),
|
||||
],
|
||||
async onStateless() {
|
||||
t.fail();
|
||||
},
|
||||
});
|
||||
|
||||
newHocuspocusProvider(t, anotherServer, {
|
||||
onSynced() {
|
||||
resolveRemoteSynced();
|
||||
},
|
||||
onStateless({ payload }) {
|
||||
if (payload === barrier) {
|
||||
resolveRemoteBarrier();
|
||||
return;
|
||||
}
|
||||
remotePayloads.push(payload);
|
||||
},
|
||||
});
|
||||
|
||||
const provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
resolveSenderSynced();
|
||||
},
|
||||
onStateless({ payload }) {
|
||||
if (payload === response) {
|
||||
resolveResponse();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([remoteSynced, senderSynced]);
|
||||
provider.sendStateless("ok");
|
||||
await responseReceived;
|
||||
|
||||
const document = server.documents.get("hocuspocus-test");
|
||||
if (!document) {
|
||||
throw new Error("Expected the document to be loaded");
|
||||
}
|
||||
// Redis preserves publish order, so receiving this broadcast proves any
|
||||
// accidental propagation of the earlier response has already arrived.
|
||||
document.broadcastStateless(barrier);
|
||||
await remoteBarrierReceived;
|
||||
|
||||
t.deepEqual(remotePayloads, []);
|
||||
});
|
||||
155
tests/extension-redis/onStoreDocument.ts
Normal file
155
tests/extension-redis/onStoreDocument.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import test from 'ava'
|
||||
import type { onStoreDocumentPayload } from '@hocuspocus/server'
|
||||
import { Redis } from '@hocuspocus/extension-redis'
|
||||
import type { HocuspocusProvider } from '@hocuspocus/provider'
|
||||
import { uuidv4 } from 'lib0/random'
|
||||
import { newHocuspocus, newHocuspocusProvider, redisConnectionSettings } from '../utils/index.ts'
|
||||
|
||||
test('stores documents without conflicts', async t => {
|
||||
await new Promise(async resolve => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let anotherProvider: HocuspocusProvider
|
||||
|
||||
class CustomStorageExtension {
|
||||
async onStoreDocument({ document }: onStoreDocumentPayload) {
|
||||
t.is(document.getArray('foo').get(0), 'bar')
|
||||
t.is(document.getArray('foo').get(0), anotherProvider.document.getArray('foo').get(0))
|
||||
|
||||
resolve('done')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
name: 'redis-1',
|
||||
extensions: [
|
||||
new Redis({
|
||||
...redisConnectionSettings,
|
||||
identifier: `server${uuidv4()}`,
|
||||
prefix: 'extension-redis/onStoreDocument1',
|
||||
}),
|
||||
new CustomStorageExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
const anotherServer = await newHocuspocus(t, {
|
||||
name: 'redis-2',
|
||||
extensions: [
|
||||
new Redis({
|
||||
...redisConnectionSettings,
|
||||
identifier: `anotherServer${uuidv4()}`,
|
||||
prefix: 'extension-redis/onStoreDocument1',
|
||||
}),
|
||||
new CustomStorageExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
newHocuspocusProvider(t, server)
|
||||
|
||||
anotherProvider = newHocuspocusProvider(t, anotherServer, {
|
||||
onSynced() {
|
||||
// once we're setup make an edit on anotherProvider, if all succeeds the onStoreDocument
|
||||
// callback will be called after the debounce period and all docs will
|
||||
// be identical
|
||||
anotherProvider.document.getArray('foo').insert(0, ['bar'])
|
||||
anotherProvider.disconnect()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('stores documents when the last client disconnects', async t => {
|
||||
await new Promise(async resolve => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let provider: HocuspocusProvider
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
extensions: [
|
||||
new Redis({
|
||||
prefix: 'extension-redis/onStoreDocument2',
|
||||
...redisConnectionSettings,
|
||||
}),
|
||||
],
|
||||
onStoreDocument: async ({ document }) => {
|
||||
t.is(
|
||||
provider.document.getArray('foo').get(0),
|
||||
document.getArray('foo').get(0),
|
||||
)
|
||||
|
||||
resolve('done')
|
||||
},
|
||||
})
|
||||
|
||||
provider = newHocuspocusProvider(t, server, {
|
||||
onSynced() {
|
||||
provider.document.getArray('foo').insert(0, ['bar'])
|
||||
provider.disconnect()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('document gets unloaded on both servers after disconnection', async t => {
|
||||
await new Promise(async resolve => {
|
||||
class CustomStorageExtension {
|
||||
priority = 10
|
||||
|
||||
onStoreDocument({ document }: onStoreDocumentPayload) {
|
||||
console.log('storing')
|
||||
return new Promise(resolve2 => {
|
||||
setTimeout(() => {
|
||||
console.log('stored')
|
||||
|
||||
resolve2('')
|
||||
}, 3000)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const server = await newHocuspocus(t, {
|
||||
name: 'redis-1',
|
||||
extensions: [
|
||||
new Redis({
|
||||
...redisConnectionSettings,
|
||||
prefix: 'extension-redis/onStoreDocument3',
|
||||
}),
|
||||
new CustomStorageExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
const anotherServer = await newHocuspocus(t, {
|
||||
name: 'redis-2',
|
||||
extensions: [
|
||||
new Redis({
|
||||
...redisConnectionSettings,
|
||||
prefix: 'extension-redis/onStoreDocument3',
|
||||
}),
|
||||
new CustomStorageExtension(),
|
||||
],
|
||||
})
|
||||
|
||||
const provider = newHocuspocusProvider(t, server)
|
||||
|
||||
const anotherProvider = newHocuspocusProvider(t, anotherServer, {
|
||||
onSynced() {
|
||||
// once we're setup make an edit on anotherProvider, if all succeeds the onStoreDocument
|
||||
// callback will be called after the debounce period and all docs will
|
||||
// be identical
|
||||
anotherProvider.document.getArray('foo').insert(0, ['bar'])
|
||||
provider.document.getArray('foo2').insert(0, ['bar'])
|
||||
|
||||
setTimeout(() => {
|
||||
provider.configuration.websocketProvider.disconnect()
|
||||
anotherProvider.configuration.websocketProvider.disconnect()
|
||||
|
||||
setTimeout(() => {
|
||||
t.is(anotherServer.documents.size, 0)
|
||||
t.is(server.documents.size, 0)
|
||||
|
||||
resolve('')
|
||||
}, 10000) // must be higher than CustomStorageExtension delay (3s) + RedisExtension.disconnectDelay (1s)
|
||||
}, 1500)
|
||||
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
74
tests/extension-redis/openDirectConnection.ts
Normal file
74
tests/extension-redis/openDirectConnection.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Redis } from "@hocuspocus/extension-redis";
|
||||
import test from "ava";
|
||||
import { newHocuspocus, redisConnectionSettings, sleep } from "../utils/index.ts";
|
||||
|
||||
const newRedisServer = (t: any, prefix: string, identifier: string, options = {}) =>
|
||||
newHocuspocus(t, {
|
||||
extensions: [
|
||||
new Redis({
|
||||
...redisConnectionSettings,
|
||||
identifier: `${identifier}${crypto.randomUUID()}`,
|
||||
prefix,
|
||||
// Keep teardown quick — these delays are unrelated to load-time sync.
|
||||
disconnectDelay: 100,
|
||||
...options,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
test("direct connection on a second instance sees edits that only live in another instance's memory", async (t) => {
|
||||
const prefix = `extension-redis/openDirectConnection-${crypto.randomUUID()}`;
|
||||
|
||||
const serverA = await newRedisServer(t, prefix, "serverA");
|
||||
const serverB = await newRedisServer(t, prefix, "serverB");
|
||||
|
||||
// Open + modify the document on server A and keep the connection open so the
|
||||
// document stays in memory on A and is never persisted to storage. The only
|
||||
// way server B can learn about this edit is via the Redis sync handshake.
|
||||
const directA = await serverA.openDirectConnection("sync-on-open");
|
||||
await directA.transact((doc) => {
|
||||
doc.getMap("config").set("foo", "bar");
|
||||
});
|
||||
|
||||
// Open a direct connection on server B and read immediately. Without an
|
||||
// inbound-sync wait in afterLoadDocument, B only has the (empty) state it
|
||||
// loaded from storage, so this read sees `undefined`.
|
||||
const directB = await serverB.openDirectConnection("sync-on-open");
|
||||
|
||||
let seenValue: unknown;
|
||||
await directB.transact((doc) => {
|
||||
seenValue = doc.getMap("config").get("foo");
|
||||
});
|
||||
|
||||
t.is(seenValue, "bar");
|
||||
|
||||
await directB.disconnect();
|
||||
await directA.disconnect();
|
||||
});
|
||||
|
||||
test("openDirectConnection on a single instance does not stall waiting for absent peers", async (t) => {
|
||||
const prefix = `extension-redis/openDirectConnection-solo-${crypto.randomUUID()}`;
|
||||
|
||||
// A generous sync timeout: if presence detection is broken and we wait for
|
||||
// peers that don't exist, this load would take ~5s instead of being instant.
|
||||
const server = await newRedisServer(t, prefix, "solo", {
|
||||
awaitInitialSyncTimeout: 5000,
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
const direct = await server.openDirectConnection("solo-doc");
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
t.true(
|
||||
elapsed < 2000,
|
||||
`openDirectConnection should not block on absent peers (took ${elapsed}ms)`,
|
||||
);
|
||||
|
||||
await direct.transact((doc) => {
|
||||
doc.getMap("config").set("foo", "bar");
|
||||
});
|
||||
t.is(direct.document?.getMap("config").get("foo"), "bar");
|
||||
|
||||
await direct.disconnect();
|
||||
await sleep(50);
|
||||
});
|
||||
205
tests/extension-redis/publishCoalescing.ts
Normal file
205
tests/extension-redis/publishCoalescing.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
173
tests/extension-redis/syncStep2Routing.ts
Normal file
173
tests/extension-redis/syncStep2Routing.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Redis } from "@hocuspocus/extension-redis";
|
||||
import { IncomingMessage, MessageType } from "@hocuspocus/server";
|
||||
import test from "ava";
|
||||
import {
|
||||
newHocuspocus,
|
||||
redisConnectionSettings,
|
||||
sleep,
|
||||
} from "../utils/index.ts";
|
||||
|
||||
const newRedisServer = async (t: any, prefix: string, identifier: string) => {
|
||||
const extension = new Redis({
|
||||
...redisConnectionSettings,
|
||||
identifier: `${identifier}-${crypto.randomUUID()}`,
|
||||
prefix,
|
||||
disconnectDelay: 100,
|
||||
});
|
||||
|
||||
const server = await newHocuspocus(t, { extensions: [extension] });
|
||||
|
||||
return { server, extension };
|
||||
};
|
||||
|
||||
/** Simulate lost pub/sub delivery: stop feeding inbound messages to the extension. */
|
||||
const silence = (extension: Redis) => {
|
||||
const sub = (extension as any).sub;
|
||||
const handler = (extension as any).handleIncomingMessage;
|
||||
sub.removeListener("messageBuffer", handler);
|
||||
|
||||
return () => sub.on("messageBuffer", handler);
|
||||
};
|
||||
|
||||
test("a SyncStep2 computed for another instance must not truncate a stale bystander", async (t) => {
|
||||
const prefix = `extension-redis/crossInstanceSyncStep2-${crypto.randomUUID()}`;
|
||||
const docName = "poison";
|
||||
|
||||
const a = await newRedisServer(t, prefix, "serverA");
|
||||
const b = await newRedisServer(t, prefix, "serverB");
|
||||
const c = await newRedisServer(t, prefix, "serverC");
|
||||
|
||||
const directA = await a.server.openDirectConnection(docName);
|
||||
const directB = await b.server.openDirectConnection(docName);
|
||||
const directC = await c.server.openDirectConnection(docName);
|
||||
|
||||
// All three instances hold the document and agree on "OLD".
|
||||
await directA.transact((doc) => {
|
||||
doc.getText("t").insert(0, "OLD");
|
||||
});
|
||||
await sleep(500);
|
||||
|
||||
t.is(directA.document!.getText("t").toString(), "OLD", "A starts at OLD");
|
||||
t.is(directB.document!.getText("t").toString(), "OLD", "B starts at OLD");
|
||||
t.is(directC.document!.getText("t").toString(), "OLD", "C starts at OLD");
|
||||
|
||||
// A misses the rewrite (Redis pub/sub is fire-and-forget, so a dropped
|
||||
// message, a reconnect, or a busy process all produce this).
|
||||
const unsilence = silence(a.extension);
|
||||
|
||||
await directB.transact((doc) => {
|
||||
const text = doc.getText("t");
|
||||
text.delete(0, text.length);
|
||||
text.insert(0, "NEW");
|
||||
});
|
||||
await sleep(500);
|
||||
|
||||
t.is(directB.document!.getText("t").toString(), "NEW", "B rewrote to NEW");
|
||||
t.is(directC.document!.getText("t").toString(), "NEW", "C received NEW");
|
||||
t.is(directA.document!.getText("t").toString(), "OLD", "A is stale at OLD");
|
||||
|
||||
unsilence();
|
||||
|
||||
// Record every state A's document passes through from here on.
|
||||
const seen: string[] = [];
|
||||
directA.document!.on("update", () => {
|
||||
seen.push(directA.document!.getText("t").toString());
|
||||
});
|
||||
|
||||
// Any change on C publishes a SyncStep1 with C's (up-to-date) state vector.
|
||||
// B answers it with SyncStep2 = encodeStateAsUpdate(B, C.sv) — zero structs
|
||||
// plus the full delete set — on the shared document channel, where A also
|
||||
// hears it.
|
||||
await directC.transact((doc) => {
|
||||
doc.getMap("noise").set("k", 1);
|
||||
});
|
||||
await sleep(500);
|
||||
|
||||
t.log("states A passed through:", JSON.stringify(seen));
|
||||
t.log("A final:", JSON.stringify(directA.document!.getText("t").toString()));
|
||||
|
||||
t.false(
|
||||
seen.includes(""),
|
||||
"A must never observe an empty document: it would broadcast that to its own clients and can persist it",
|
||||
);
|
||||
|
||||
await directA.disconnect();
|
||||
await directB.disconnect();
|
||||
await directC.disconnect();
|
||||
});
|
||||
|
||||
/** `messageYjsSyncStep2` from y-protocols/sync, which is not resolvable here. */
|
||||
const SYNC_STEP_2 = 1;
|
||||
|
||||
test("sync replies are published to the requester's reply channel only", async (t) => {
|
||||
const prefix = `extension-redis/syncStep2Routing-channel-${crypto.randomUUID()}`;
|
||||
const docName = "routing";
|
||||
|
||||
const identifierA = `serverA-${crypto.randomUUID()}`;
|
||||
const extensionA = new Redis({
|
||||
...redisConnectionSettings,
|
||||
identifier: identifierA,
|
||||
prefix,
|
||||
disconnectDelay: 100,
|
||||
});
|
||||
const serverA = await newHocuspocus(t, { extensions: [extensionA] });
|
||||
|
||||
const b = await newRedisServer(t, prefix, "serverB");
|
||||
|
||||
const published: Array<{ channel: string; syncType?: number }> = [];
|
||||
const publisher = (b.extension as any).pub;
|
||||
const original = publisher.publish.bind(publisher);
|
||||
|
||||
publisher.publish = (channel: string, payload: Buffer) => {
|
||||
const [, messageBuffer] = (b.extension as any).decodeMessage(payload);
|
||||
const message = new IncomingMessage(messageBuffer);
|
||||
|
||||
message.readVarString(); // document name
|
||||
|
||||
const type = message.readVarUint();
|
||||
|
||||
published.push({
|
||||
channel,
|
||||
syncType:
|
||||
type === MessageType.Sync || type === MessageType.SyncReply
|
||||
? message.readVarUint()
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return original(channel, payload);
|
||||
};
|
||||
|
||||
// B holds the document with state that only lives in its memory.
|
||||
const directB = await b.server.openDirectConnection(docName);
|
||||
await directB.transact((doc) => {
|
||||
doc.getText("t").insert(0, "hello");
|
||||
});
|
||||
await sleep(300);
|
||||
published.length = 0;
|
||||
|
||||
// A loads the same document, so B has to answer A's SyncStep1.
|
||||
const directA = await serverA.openDirectConnection(docName);
|
||||
await sleep(300);
|
||||
|
||||
t.is(directA.document!.getText("t").toString(), "hello", "A synced from B");
|
||||
|
||||
const replies = published.filter((entry) => entry.syncType === SYNC_STEP_2);
|
||||
|
||||
t.true(replies.length > 0, "B answered with its state");
|
||||
|
||||
for (const reply of replies) {
|
||||
t.is(reply.channel, `${prefix}#reply:${identifierA}`);
|
||||
}
|
||||
|
||||
t.false(
|
||||
published.some(
|
||||
(entry) =>
|
||||
entry.channel === `${prefix}:${docName}` &&
|
||||
entry.syncType === SYNC_STEP_2,
|
||||
),
|
||||
"no state is published on the shared document channel",
|
||||
);
|
||||
|
||||
await directA.disconnect();
|
||||
await directB.disconnect();
|
||||
});
|
||||
Reference in New Issue
Block a user