Defer replication runtime database acquisition

This commit is contained in:
vorotamoroz
2026-08-30 16:39:15 +00:00
parent 6364988453
commit 23dd4ab87e
6 changed files with 100 additions and 34 deletions
@@ -44,11 +44,16 @@ type ReplicateResultProcessorServices = Pick<
* awaiting it. Result application can still be running inside work admitted by * awaiting it. Result application can still be running inside work admitted by
* that owner, so awaiting retirement here could make each side wait for the * that owner, so awaiting retirement here could make each side wait for the
* other to finish. * other to finish.
*
* Runtime databases are deliberately obtained through operation-time
* accessors. Feature composition precedes their initialisation, and database
* reset may replace their backing instances, so retaining an earlier concrete
* database would be invalid.
*/ */
interface ReplicateResultProcessorContext { interface ReplicateResultProcessorContext {
readonly currentSettings: () => ReplicateResultProcessorSettings; readonly currentSettings: () => ReplicateResultProcessorSettings;
readonly keyValueDB: LiveSyncBaseCore["kvDB"]; readonly getKeyValueDB: () => LiveSyncBaseCore["kvDB"];
readonly localDatabase: LiveSyncBaseCore["localDatabase"]; readonly getLocalDatabase: () => LiveSyncBaseCore["localDatabase"];
readonly requestActiveReplicatorRetirement: () => void; readonly requestActiveReplicatorRetirement: () => void;
readonly runLocalApplicationActivity: <T>( readonly runLocalApplicationActivity: <T>(
task: () => T | PromiseLike<T>, task: () => T | PromiseLike<T>,
@@ -77,7 +82,7 @@ export class ReplicateResultProcessor {
constructor(private readonly context: ReplicateResultProcessorContext) {} constructor(private readonly context: ReplicateResultProcessorContext) {}
private get localDatabase() { private get localDatabase() {
return this.context.localDatabase; return this.context.getLocalDatabase();
} }
private get services() { private get services() {
return this.context.services; return this.context.services;
@@ -119,7 +124,7 @@ export class ReplicateResultProcessor {
queued: this._queuedChanges.slice(), queued: this._queuedChanges.slice(),
processing: this._processingChanges.slice(), processing: this._processingChanges.slice(),
} satisfies ReplicateResultProcessorState; } satisfies ReplicateResultProcessorState;
await this.context.keyValueDB.set(KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT, snapshot); await this.context.getKeyValueDB().set(KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT, snapshot);
this.log( this.log(
`Snapshot taken. Queued: ${snapshot.queued.length}, Processing: ${snapshot.processing.length}`, `Snapshot taken. Queued: ${snapshot.queued.length}, Processing: ${snapshot.processing.length}`,
LOG_LEVEL_DEBUG LOG_LEVEL_DEBUG
@@ -141,9 +146,9 @@ export class ReplicateResultProcessor {
* Restore from snapshot. * Restore from snapshot.
*/ */
public async restoreFromSnapshot() { public async restoreFromSnapshot() {
const snapshot = await this.context.keyValueDB.get<ReplicateResultProcessorState>( const snapshot = await this.context
KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT .getKeyValueDB()
); .get<ReplicateResultProcessorState>(KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT);
if (snapshot) { if (snapshot) {
// Restoring the snapshot re-runs processing for both queued and processing items. // Restoring the snapshot re-runs processing for both queued and processing items.
const newQueue = [...snapshot.processing, ...snapshot.queued, ...this._queuedChanges]; const newQueue = [...snapshot.processing, ...snapshot.queued, ...this._queuedChanges];
@@ -56,8 +56,8 @@ function setup(options: SetupOptions = {}) {
}; };
const processor = new ReplicateResultProcessor({ const processor = new ReplicateResultProcessor({
currentSettings: () => ({ maxMTimeForReflectEvents: 0, suspendParseReplicationResult: false }), currentSettings: () => ({ maxMTimeForReflectEvents: 0, suspendParseReplicationResult: false }),
keyValueDB: core.kvDB, getKeyValueDB: () => core.kvDB,
localDatabase: core.localDatabase, getLocalDatabase: () => core.localDatabase,
requestActiveReplicatorRetirement: () => { requestActiveReplicatorRetirement: () => {
void onCloseActiveReplication(); void onCloseActiveReplication();
}, },
@@ -95,14 +95,16 @@ describe("ReplicateResultProcessor", () => {
const findAllNormalDocs = vi.fn(async function* () { const findAllNormalDocs = vi.fn(async function* () {
yield* documents; yield* documents;
}); });
const getLocalDatabase = vi.fn(() => ({ findAllNormalDocs }));
const processor = new ReplicateResultProcessor({ const processor = new ReplicateResultProcessor({
localDatabase: { findAllNormalDocs }, getLocalDatabase,
} as never); } as never);
const enqueueAll = vi.spyOn(processor, "enqueueAll").mockImplementation(() => undefined); const enqueueAll = vi.spyOn(processor, "enqueueAll").mockImplementation(() => undefined);
await expect(processor.reprocessStoredDocuments()).resolves.toBe(2); await expect(processor.reprocessStoredDocuments()).resolves.toBe(2);
expect(findAllNormalDocs).toHaveBeenCalledOnce(); expect(findAllNormalDocs).toHaveBeenCalledOnce();
expect(getLocalDatabase).toHaveBeenCalledOnce();
expect(enqueueAll).toHaveBeenCalledOnce(); expect(enqueueAll).toHaveBeenCalledOnce();
expect(enqueueAll).toHaveBeenCalledWith(documents); expect(enqueueAll).toHaveBeenCalledWith(documents);
}); });
@@ -17,9 +17,11 @@ type CentralCompatibilityRecoveryServices = Pick<
"API" | "appLifecycle" | "replicator" | "tweakValue" "API" | "appLifecycle" | "replicator" | "tweakValue"
>; >;
/** Collaborators for applying a compatibility decision to its failed publication. */
interface CentralCompatibilityRecoveryContext { interface CentralCompatibilityRecoveryContext {
readonly confirm: LiveSyncBaseCore["confirm"]; readonly confirm: LiveSyncBaseCore["confirm"];
readonly localDatabase: LiveSyncBaseCore["localDatabase"]; /** Obtain the database only when recovery runs, after initialisation or reset. */
readonly getLocalDatabase: () => LiveSyncBaseCore["localDatabase"];
readonly rebuilder: LiveSyncBaseCore["rebuilder"]; readonly rebuilder: LiveSyncBaseCore["rebuilder"];
readonly services: CentralCompatibilityRecoveryServices; readonly services: CentralCompatibilityRecoveryServices;
} }
@@ -56,7 +58,7 @@ export function createCentralCompatibilityRecovery(context: CentralCompatibility
) { ) {
Logger("The remote database has been cleaned.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); Logger("The remote database has been cleaned.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
await skipIfDuplicated("cleanup", async () => { await skipIfDuplicated("cleanup", async () => {
const count = await purgeUnreferencedChunks(context.localDatabase.localDatabase, true); const count = await purgeUnreferencedChunks(context.getLocalDatabase().localDatabase, true);
const message = `The remote database has been cleaned up. const message = `The remote database has been cleaned up.
To synchronize, this device must be also cleaned up. ${count} chunk(s) will be erased from this device. To synchronize, this device must be also cleaned up. ${count} chunk(s) will be erased from this device.
However, If there are many chunks to be deleted, maybe fetching again is faster. However, If there are many chunks to be deleted, maybe fetching again is faster.
@@ -83,6 +85,7 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
if (activeContext !== expectedContext) return; if (activeContext !== expectedContext) return;
const replicator = activeContext.replicator; const replicator = activeContext.replicator;
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return; if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
const localDatabase = context.getLocalDatabase();
const remoteDatabase = await replicator.connectRemoteCouchDBWithSetting( const remoteDatabase = await replicator.connectRemoteCouchDBWithSetting(
setting, setting,
context.services.API.isMobile(), context.services.API.isMobile(),
@@ -94,16 +97,16 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
} }
try { try {
await purgeUnreferencedChunks(context.localDatabase.localDatabase, false); await purgeUnreferencedChunks(localDatabase.localDatabase, false);
context.localDatabase.clearCaches(); localDatabase.clearCaches();
const replicated = await context.services.replicator.runFiniteReplicationActivity( const replicated = await context.services.replicator.runFiniteReplicationActivity(
() => replicator.openOneShotReplication(setting, showMessage, false, "sync", true), () => replicator.openOneShotReplication(setting, showMessage, false, "sync", true),
{ label: "replication" } { label: "replication" }
); );
if (replicated) { if (replicated) {
await balanceChunkPurgedDBs(context.localDatabase.localDatabase, remoteDatabase.db); await balanceChunkPurgedDBs(localDatabase.localDatabase, remoteDatabase.db);
await purgeUnreferencedChunks(context.localDatabase.localDatabase, false); await purgeUnreferencedChunks(localDatabase.localDatabase, false);
context.localDatabase.clearCaches(); localDatabase.clearCaches();
await replicator.markRemoteResolved(setting); await replicator.markRemoteResolved(setting);
Logger( Logger(
"The local database has been cleaned up.", "The local database has been cleaned up.",
@@ -28,7 +28,7 @@ describe("central compatibility recovery", () => {
try { try {
const recovery = createCentralCompatibilityRecovery({ const recovery = createCentralCompatibilityRecovery({
confirm: {}, confirm: {},
localDatabase: {}, getLocalDatabase: () => ({}),
rebuilder: {}, rebuilder: {},
services: { services: {
appLifecycle: {}, appLifecycle: {},
@@ -75,7 +75,7 @@ describe("central compatibility recovery", () => {
}); });
const recovery = createCentralCompatibilityRecovery({ const recovery = createCentralCompatibilityRecovery({
confirm: {}, confirm: {},
localDatabase: {}, getLocalDatabase: () => ({}),
rebuilder: {}, rebuilder: {},
services: { services: {
appLifecycle: {}, appLifecycle: {},
@@ -137,7 +137,7 @@ describe("central compatibility recovery", () => {
); );
const recovery = createCentralCompatibilityRecovery({ const recovery = createCentralCompatibilityRecovery({
confirm: {}, confirm: {},
localDatabase: {}, getLocalDatabase: () => ({}),
rebuilder: {}, rebuilder: {},
services: { services: {
appLifecycle: {}, appLifecycle: {},
@@ -180,7 +180,7 @@ describe("central compatibility recovery", () => {
confirm: { confirm: {
askSelectStringDialogue: vi.fn(async (_message: string, choices: string[]) => choices[1]), askSelectStringDialogue: vi.fn(async (_message: string, choices: string[]) => choices[1]),
}, },
localDatabase: {}, getLocalDatabase: () => ({}),
rebuilder: {}, rebuilder: {},
services: { services: {
appLifecycle: { scheduleRestart: vi.fn() }, appLifecycle: { scheduleRestart: vi.fn() },
@@ -228,9 +228,10 @@ describe("central compatibility recovery", () => {
task(expectedContext) task(expectedContext)
); );
const localDatabase = { localDatabase: {}, clearCaches: vi.fn() }; const localDatabase = { localDatabase: {}, clearCaches: vi.fn() };
const getLocalDatabase = vi.fn(() => localDatabase);
const recovery = createCentralCompatibilityRecovery({ const recovery = createCentralCompatibilityRecovery({
confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") }, confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") },
localDatabase, getLocalDatabase,
rebuilder: {}, rebuilder: {},
services: { services: {
appLifecycle: {}, appLifecycle: {},
@@ -258,6 +259,7 @@ describe("central compatibility recovery", () => {
activityFinished.mock.invocationCallOrder[0] activityFinished.mock.invocationCallOrder[0]
); );
expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce(); expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce();
expect(getLocalDatabase).toHaveBeenCalledTimes(2);
expect(close).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledOnce();
expect(close.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]); expect(close.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]);
}); });
+3 -3
View File
@@ -39,8 +39,8 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
: undefined; : undefined;
const resultProcessor = new ReplicateResultProcessor({ const resultProcessor = new ReplicateResultProcessor({
currentSettings: () => services.setting.currentSettings(), currentSettings: () => services.setting.currentSettings(),
keyValueDB: services.keyValueDB.kvDB, getKeyValueDB: () => services.keyValueDB.kvDB,
localDatabase: core.localDatabase, getLocalDatabase: () => core.localDatabase,
requestActiveReplicatorRetirement: () => { requestActiveReplicatorRetirement: () => {
// Do not await a retirement transition from result application: it // Do not await a retirement transition from result application: it
// may be draining the replication work which delivered this item. // may be draining the replication work which delivered this item.
@@ -81,7 +81,7 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
const securitySeedPreflight = createSecuritySeedPreflight(unresolvedErrorManager, preflightContext); const securitySeedPreflight = createSecuritySeedPreflight(unresolvedErrorManager, preflightContext);
const centralCompatibilityRecovery = createCentralCompatibilityRecovery({ const centralCompatibilityRecovery = createCentralCompatibilityRecovery({
confirm: core.confirm, confirm: core.confirm,
localDatabase: core.localDatabase, getLocalDatabase: () => core.localDatabase,
rebuilder: core.rebuilder, rebuilder: core.rebuilder,
services: { services: {
API: services.API, API: services.API,
@@ -6,8 +6,29 @@ import { useReplicationFeature } from "./index";
type BooleanHandler = (showMessage: boolean) => Promise<boolean>; type BooleanHandler = (showMessage: boolean) => Promise<boolean>;
type ParseHandler = (documents: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>; type ParseHandler = (documents: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>;
type KeyValueDBFixture = {
readonly kvDB: {
get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<unknown>;
};
};
type SetupOptions = {
readonly getLocalDatabase?: () => object;
readonly keyValueDB?: KeyValueDBFixture;
readonly onCloseActiveReplication?: () => Promise<boolean>;
};
function setup(onCloseActiveReplication = vi.fn(async () => true)) { function setup(options: SetupOptions = {}) {
const {
getLocalDatabase = () => ({}),
keyValueDB = {
kvDB: {
get: vi.fn(async () => undefined),
set: vi.fn(async () => undefined),
},
},
onCloseActiveReplication = vi.fn(async () => true),
} = options;
const read = vi.fn(async () => new Uint8Array([1])); const read = vi.fn(async () => new Uint8Array([1]));
const dispose = vi.fn(async () => undefined); const dispose = vi.fn(async () => undefined);
const createRemoteResource = vi.fn(async () => ({ read, dispose })); const createRemoteResource = vi.fn(async () => ({ read, dispose }));
@@ -24,12 +45,7 @@ function setup(onCloseActiveReplication = vi.fn(async () => true)) {
}, },
context: createServiceContext(), context: createServiceContext(),
databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } }, databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } },
keyValueDB: { keyValueDB,
kvDB: {
get: vi.fn(async () => undefined),
set: vi.fn(async () => undefined),
},
},
path: { getPath: vi.fn((entry: { path: string }) => entry.path) }, path: { getPath: vi.fn((entry: { path: string }) => entry.path) },
replication: { replication: {
onBeforeReplicate: { onBeforeReplicate: {
@@ -59,7 +75,9 @@ function setup(onCloseActiveReplication = vi.fn(async () => true)) {
}; };
const core = { const core = {
confirm: {}, confirm: {},
localDatabase: {}, get localDatabase() {
return getLocalDatabase();
},
rebuilder: {}, rebuilder: {},
services, services,
}; };
@@ -80,6 +98,42 @@ function setup(onCloseActiveReplication = vi.fn(async () => true)) {
} }
describe("replication serviceFeature composition", () => { describe("replication serviceFeature composition", () => {
it("does not acquire the local database while composing result handlers", () => {
const acquireLocalDatabase = vi.fn(() => {
throw new Error("Local database is not ready yet");
});
expect(() => setup({ getLocalDatabase: acquireLocalDatabase })).not.toThrow();
expect(acquireLocalDatabase).not.toHaveBeenCalled();
});
it("acquires the current key-value database only when snapshot recovery starts", async () => {
const backingDatabase = {
get: vi.fn(async () => undefined),
set: vi.fn(async () => undefined),
};
let isReady = false;
const acquireKeyValueDB = vi.fn(() => {
if (!isReady) throw new Error("KeyValueDB is not initialized yet");
return backingDatabase;
});
const keyValueDB = {
get kvDB() {
return acquireKeyValueDB();
},
};
const { beforeReplicateHandlers } = setup({ keyValueDB });
expect(acquireKeyValueDB).not.toHaveBeenCalled();
isReady = true;
const restoreSnapshot = beforeReplicateHandlers.get(100);
expect(restoreSnapshot).toBeDefined();
await expect(restoreSnapshot!(false)).resolves.toBe(true);
expect(acquireKeyValueDB).toHaveBeenCalledOnce();
expect(backingDatabase.get).toHaveBeenCalledOnce();
});
it("refreshes and disposes the remote Security Seed before central replication", async () => { it("refreshes and disposes the remote Security Seed before central replication", async () => {
const { centralRemoteHandlers, createRemoteResource, dispose, read } = setup(); const { centralRemoteHandlers, createRemoteResource, dispose, read } = setup();
@@ -109,7 +163,7 @@ describe("replication serviceFeature composition", () => {
it("requests owner retirement without awaiting the transition from result application", async () => { it("requests owner retirement without awaiting the transition from result application", async () => {
const retirement = promiseWithResolvers<boolean>(); const retirement = promiseWithResolvers<boolean>();
const onCloseActiveReplication = vi.fn(() => retirement.promise); const onCloseActiveReplication = vi.fn(() => retirement.promise);
const harness = setup(onCloseActiveReplication); const harness = setup({ onCloseActiveReplication });
const versionInfo = { const versionInfo = {
_id: "versioninfo", _id: "versioninfo",
_rev: "1-test", _rev: "1-test",