Compose replication scheduling as a service feature

This commit is contained in:
vorotamoroz
2026-08-28 06:05:29 +00:00
parent fc160ee060
commit f7206b1a6e
15 changed files with 850 additions and 525 deletions
@@ -16,8 +16,12 @@ vi.mock("octagonal-wheels/concurrency/task", () => taskMocks);
import { ModuleConflictResolver } from "../coreFeatures/ModuleConflictResolver";
import { ModuleObsidianEvents } from "../essentialObsidian/ModuleObsidianEvents";
import { ModulePeriodicProcess } from "./ModulePeriodicProcess";
import { ModuleReplicationLifecycle } from "./ModuleReplicationLifecycle";
import {
createReplicationSchedulingContext,
realiseReplicationScheduling,
resumeReplicationScheduling,
runPeriodicReplication,
} from "@/serviceFeatures/replicationScheduling";
import { ModuleReplicator } from "./ModuleReplicator";
function createApi() {
@@ -97,11 +101,20 @@ describe("automatic replication triggers while P2P is active", () => {
const core = {
_services: services,
services,
settings: p2pSettings({ periodicReplication: true }),
settings: p2pSettings({ periodicReplication: true, syncOnStart: false }),
} as any;
const module = new ModulePeriodicProcess(core);
const context = createReplicationSchedulingContext({
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => false),
currentSettings: vi.fn(() => core.settings),
replicateUnattended,
startContinuous: vi.fn(async () => ({ status: "completed" as const })),
timer: { enable: vi.fn(), disable: vi.fn() },
log: vi.fn(),
});
await module.periodicSyncProcessor.process();
resumeReplicationScheduling(context);
await runPeriodicReplication(context);
expect(replicateUnattended).toHaveBeenCalledOnce();
expect(replicateUnattended).toHaveBeenCalledWith({
@@ -203,8 +216,6 @@ describe("recurring replication scheduling precedence", () => {
});
function createRecurringSchedulingHarness() {
const resumeHandlers: Array<() => Promise<boolean>> = [];
const settingRealisedHandlers: Array<() => Promise<boolean>> = [];
let resolveContinuous!: (
outcome: { status: "completed" } | { status: "blocked"; reason: "capability-not-applicable" }
) => void;
@@ -225,42 +236,36 @@ describe("recurring replication scheduling precedence", () => {
periodicReplication: true,
periodicReplicationInterval: 60,
};
const services = {
API,
appLifecycle: {
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => false),
onResumed: { addHandler: vi.fn((handler: () => Promise<boolean>) => resumeHandlers.push(handler)) },
onSuspending: { addHandler: vi.fn() },
onUnload: { addHandler: vi.fn() },
},
control: { hasUnloaded: vi.fn(() => false) },
replication: {
startContinuous,
replicateUnattended: vi.fn(async () => ({ status: "completed" as const })),
},
setting: {
currentSettings: vi.fn(() => settings),
onBeforeRealiseSetting: { addHandler: vi.fn() },
onSettingRealised: {
addHandler: vi.fn((handler: () => Promise<boolean>) => settingRealisedHandlers.push(handler)),
const context = createReplicationSchedulingContext({
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => false),
currentSettings: vi.fn(() => settings),
startContinuous,
replicateUnattended: vi.fn(async () => ({ status: "completed" as const })),
timer: {
enable: (interval) => {
API.setInterval(vi.fn(), interval);
},
disable: () => {
API.clearInterval(0);
},
},
};
const core = { _services: services, services, settings } as any;
const lifecycle = new ModuleReplicationLifecycle(core);
const periodic = new ModulePeriodicProcess(core);
lifecycle.onBindFunction(core, services as never);
periodic.onBindFunction(core, services as never);
log: vi.fn(),
});
return {
API,
resolveContinuous: (
outcome: { status: "completed" } | { status: "blocked"; reason: "capability-not-applicable" }
) => resolveContinuous(outcome),
resume: async () => await Promise.all(resumeHandlers.map(async (handler) => await handler())),
realiseSettings: async () =>
await Promise.all(settingRealisedHandlers.map(async (handler) => await handler())),
resume: async () => {
resumeReplicationScheduling(context);
await Promise.resolve();
},
realiseSettings: async () => {
realiseReplicationScheduling(context);
await Promise.resolve();
},
};
}
-62
View File
@@ -1,62 +0,0 @@
import { PeriodicProcessor } from "@/common/PeriodicProcessor";
import type { LiveSyncCore } from "@/main";
import { AbstractModule } from "@/modules/AbstractModule";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
import { getReplicationSchedulingControl } from "./ReplicationScheduling";
export class ModulePeriodicProcess extends AbstractModule {
private readonly schedulingControl = getReplicationSchedulingControl(this.core);
periodicSyncProcessor = new PeriodicProcessor(this.core, async () => {
await this.services.replication.replicateUnattended({
trigger: "periodic",
interaction: NO_INTERACTION,
});
});
disablePeriodic() {
this.periodicSyncProcessor?.disable();
return Promise.resolve(true);
}
resumePeriodic() {
if (this.schedulingControl.externalPolling || this.schedulingControl.continuousOwnsRecurring) {
void this.disablePeriodic();
return Promise.resolve(true);
}
this.periodicSyncProcessor.enable(
this.settings.periodicReplication ? this.settings.periodicReplicationInterval * 1000 : 0
);
return Promise.resolve(true);
}
private _allOnUnload() {
return this.disablePeriodic();
}
private _everyBeforeRealizeSetting(): Promise<boolean> {
return this.disablePeriodic();
}
private _everyBeforeSuspendProcess(): Promise<boolean> {
return this.disablePeriodic();
}
private _everyAfterResumeProcess(): Promise<boolean> {
return this.resumePeriodic();
}
private _everyAfterRealizeSetting(): Promise<boolean> {
return this.resumePeriodic();
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
this.schedulingControl.disablePeriodic = () => {
void this.disablePeriodic();
};
this.schedulingControl.refreshPeriodic = () => {
void this.resumePeriodic();
};
if (this.schedulingControl.externalPolling || this.schedulingControl.continuousOwnsRecurring) {
void this.disablePeriodic();
}
services.appLifecycle.onUnload.addHandler(this._allOnUnload.bind(this));
services.setting.onBeforeRealiseSetting.addHandler(this._everyBeforeRealizeSetting.bind(this));
services.setting.onSettingRealised.addHandler(this._everyAfterRealizeSetting.bind(this));
services.appLifecycle.onSuspending.addHandler(this._everyBeforeSuspendProcess.bind(this));
services.appLifecycle.onResumed.addHandler(this._everyAfterResumeProcess.bind(this));
}
}
@@ -1,113 +0,0 @@
import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
import {
isReplicationCompleted,
NO_INTERACTION,
type ReplicationOutcome,
} from "@vrtmrz/livesync-commonlib/replication";
import { AbstractModule } from "@/modules/AbstractModule";
import type { LiveSyncCore } from "@/main";
import {
getReplicationSchedulingControl,
markInitialOneShotSatisfied,
setContinuousSchedulingOwnership,
setExternalPollingMode,
} from "./ReplicationScheduling";
function isCapabilityUnavailable(result: ReplicationOutcome): boolean {
return (
result.status === "blocked" &&
(result.reason === "capability-not-applicable" || result.reason === "capability-not-implemented")
);
}
/**
* Coordinates application resume with the active provider's typed roles.
* Provider implementations do not subscribe to the application lifecycle.
*/
export class ModuleReplicationLifecycle extends AbstractModule {
private readonly schedulingControl = getReplicationSchedulingControl(this.core);
private resumePromise?: Promise<boolean>;
private async runAfterResume(): Promise<boolean> {
if (this.schedulingControl.externalPolling) return true;
if (this.services.appLifecycle.isSuspended()) return true;
if (!this.services.appLifecycle.isReady()) return true;
const settings = this.services.setting.currentSettings();
if (!settings.isConfigured) {
setContinuousSchedulingOwnership(this.core, false);
return true;
}
const skipOneShot = this.schedulingControl.initialOneShotSatisfied;
if (settings.liveSync) {
// Reserve recurring ownership before the asynchronous start so a
// later resume handler cannot enable Periodic in the meantime.
setContinuousSchedulingOwnership(this.core, true);
const result = await this.services.replication.startContinuous({
trigger: "resume",
interaction: NO_INTERACTION,
});
if (!isReplicationCompleted(result)) {
setContinuousSchedulingOwnership(this.core, false);
}
// The daemon's initial finite convergence must not suppress a
// supported Continuous start. It only suppresses the fallback
// OneShot when Continuous is unavailable.
this.schedulingControl.initialOneShotSatisfied = false;
if (isCapabilityUnavailable(result) && settings.syncOnStart && !skipOneShot) {
await this.services.replication.replicateUnattended({
trigger: "resume",
interaction: NO_INTERACTION,
});
}
return true;
}
setContinuousSchedulingOwnership(this.core, false);
if (settings.syncOnStart && !skipOneShot) {
await this.services.replication.replicateUnattended({
trigger: "resume",
interaction: NO_INTERACTION,
});
}
this.schedulingControl.initialOneShotSatisfied = false;
return true;
}
private _everyAfterResumeProcess(): Promise<boolean> {
if (!this.resumePromise) {
// The lifecycle event is a short notification boundary. Keep the
// long-running OneShot/Continuous start coalesced internally, but
// let later resume handlers (P2P, periodic scheduling, and other
// modules) continue without waiting for network work to settle.
this.resumePromise = this.runAfterResume()
.catch((error) => {
this._log(error, LOG_LEVEL_VERBOSE);
return true;
})
.finally(() => {
this.resumePromise = undefined;
});
}
return Promise.resolve(true);
}
/**
* Let a CLI daemon own recurring polling without a duplicate lifecycle or
* generic periodic scheduler. This is intentionally narrower than a
* provider or Replicator control API.
*/
setExternalPollingMode(enabled: boolean): void {
setExternalPollingMode(this.core, enabled);
}
/** Mark the daemon's initial finite convergence for the next resume. */
markInitialOneShotSatisfied(): void {
markInitialOneShotSatisfied(this.core);
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.onResumed.addHandler(this._everyAfterResumeProcess.bind(this));
}
}
@@ -1,200 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { NO_INTERACTION, type ReplicationOutcome } from "@vrtmrz/livesync-commonlib/replication";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { ModuleReplicationLifecycle } from "./ModuleReplicationLifecycle";
import { getReplicationSchedulingControl, setExternalPollingMode } from "./ReplicationScheduling";
type ResumeHandler = () => Promise<boolean>;
function createResumeHarness(settings: {
liveSync: boolean;
syncOnStart: boolean;
isConfigured?: boolean;
remoteType?: string;
P2P_Enabled?: boolean;
}) {
const resumeHandlers: ResumeHandler[] = [];
const replicateUnattended = vi.fn(async (): Promise<ReplicationOutcome> => ({ status: "completed" }));
const startContinuous = vi.fn(async (): Promise<ReplicationOutcome> => ({ status: "completed" }));
const currentSettings = {
isConfigured: true,
periodicReplication: false,
...settings,
};
const services = {
context: createServiceContext(),
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
isOnline: true,
},
appLifecycle: {
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => false),
onResumed: {
addHandler: vi.fn((handler: ResumeHandler) => resumeHandlers.push(handler)),
},
},
replication: {
replicateUnattended,
startContinuous,
},
setting: {
currentSettings: vi.fn(() => currentSettings),
},
};
const core = {
_services: services,
services,
settings: currentSettings,
} as any;
const module = new ModuleReplicationLifecycle(core);
module.onBindFunction(core, services as never);
return {
core,
module,
replicateUnattended,
startContinuous,
resume: async () => await Promise.all(resumeHandlers.map((handler) => handler())),
};
}
describe("provider-independent replication resume lifecycle", () => {
it("starts one unattended OneShot when sync-on-start is enabled", async () => {
const harness = createResumeHarness({ liveSync: false, syncOnStart: true });
await harness.resume();
expect(harness.replicateUnattended).toHaveBeenCalledOnce();
expect(harness.replicateUnattended).toHaveBeenCalledWith({
trigger: "resume",
interaction: NO_INTERACTION,
});
expect(harness.startContinuous).not.toHaveBeenCalled();
});
it("falls back to sync-on-start when Continuous is not applicable", async () => {
const harness = createResumeHarness({ liveSync: true, syncOnStart: true });
harness.startContinuous.mockResolvedValue({
status: "blocked",
reason: "capability-not-applicable",
});
await harness.resume();
expect(harness.startContinuous).toHaveBeenCalledWith({
trigger: "resume",
interaction: NO_INTERACTION,
});
expect(harness.replicateUnattended).toHaveBeenCalledWith({
trigger: "resume",
interaction: NO_INTERACTION,
});
});
it("starts Continuous without a finite fallback when it is supported", async () => {
const harness = createResumeHarness({ liveSync: true, syncOnStart: true });
await harness.resume();
expect(harness.startContinuous).toHaveBeenCalledOnce();
expect(harness.replicateUnattended).not.toHaveBeenCalled();
});
it("does not fall back after an actual Continuous failure", async () => {
const harness = createResumeHarness({ liveSync: true, syncOnStart: true });
harness.startContinuous.mockResolvedValue({
status: "failed",
error: new Error("connection failed"),
});
await harness.resume();
expect(harness.replicateUnattended).not.toHaveBeenCalled();
});
it("coalesces concurrent resume callbacks", async () => {
const harness = createResumeHarness({ liveSync: false, syncOnStart: true });
let resolveReplication!: (value: { status: "completed" }) => void;
harness.replicateUnattended.mockImplementationOnce(
() => new Promise((resolve) => (resolveReplication = resolve))
);
const first = harness.resume();
const second = harness.resume();
resolveReplication({ status: "completed" });
await Promise.all([first, second]);
expect(harness.replicateUnattended).toHaveBeenCalledOnce();
});
it("does not block later resume handlers while a OneShot is running", async () => {
const harness = createResumeHarness({ liveSync: false, syncOnStart: true });
let resolveReplication!: (value: { status: "completed" }) => void;
harness.replicateUnattended.mockImplementationOnce(
() => new Promise((resolve) => (resolveReplication = resolve))
);
const resumed = harness.resume();
await expect(resumed).resolves.toEqual([true]);
expect(harness.replicateUnattended).toHaveBeenCalledOnce();
resolveReplication({ status: "completed" });
await resumed;
});
it("skips only the daemon-satisfied OneShot while allowing Continuous", async () => {
const harness = createResumeHarness({ liveSync: true, syncOnStart: true });
getReplicationSchedulingControl(harness.core).initialOneShotSatisfied = true;
harness.startContinuous.mockResolvedValue({
status: "blocked",
reason: "capability-not-applicable",
});
await harness.resume();
expect(harness.startContinuous).toHaveBeenCalledOnce();
expect(harness.replicateUnattended).not.toHaveBeenCalled();
expect(getReplicationSchedulingControl(harness.core).initialOneShotSatisfied).toBe(false);
});
it("does not start lifecycle replication while an external poller owns scheduling", async () => {
const harness = createResumeHarness({ liveSync: false, syncOnStart: true });
setExternalPollingMode(harness.core, true);
await harness.resume();
expect(harness.startContinuous).not.toHaveBeenCalled();
expect(harness.replicateUnattended).not.toHaveBeenCalled();
});
it("requests the generic finite fallback for P2P when Continuous is not applicable", async () => {
const harness = createResumeHarness({
remoteType: REMOTE_P2P,
P2P_Enabled: true,
liveSync: true,
syncOnStart: true,
});
harness.startContinuous.mockResolvedValue({
status: "blocked",
reason: "capability-not-applicable",
});
harness.replicateUnattended.mockResolvedValue({
status: "blocked",
reason: "capability-not-implemented",
});
await harness.resume();
expect(harness.startContinuous).toHaveBeenCalledOnce();
expect(harness.replicateUnattended).toHaveBeenCalledWith({
trigger: "resume",
interaction: NO_INTERACTION,
});
});
});
-49
View File
@@ -1,49 +0,0 @@
/**
* Host-owned scheduling state shared by the lifecycle coordinator and the
* CLI daemon. It deliberately contains policy state only; provider choice and
* replication execution remain in ReplicationService.
*/
export interface ReplicationSchedulingControl {
/** The daemon owns recurring polling and suppresses host automation. */
externalPolling: boolean;
/** A Continuous start is pending or accepted and therefore owns recurring synchronisation. */
continuousOwnsRecurring: boolean;
/** The daemon's initial convergence satisfies the next resume OneShot. */
initialOneShotSatisfied: boolean;
/** Registered by the periodic module so the daemon can remove an old timer. */
disablePeriodic?: () => void;
/** Reconcile the periodic timer after recurring ownership changes. */
refreshPeriodic?: () => void;
}
const controls = new WeakMap<object, ReplicationSchedulingControl>();
export function getReplicationSchedulingControl(owner: object): ReplicationSchedulingControl {
let control = controls.get(owner);
if (!control) {
control = {
externalPolling: false,
continuousOwnsRecurring: false,
initialOneShotSatisfied: false,
};
controls.set(owner, control);
}
return control;
}
export function setContinuousSchedulingOwnership(owner: object, ownsRecurring: boolean): void {
const control = getReplicationSchedulingControl(owner);
if (control.continuousOwnsRecurring === ownsRecurring) return;
control.continuousOwnsRecurring = ownsRecurring;
control.refreshPeriodic?.();
}
export function setExternalPollingMode(owner: object, enabled: boolean): void {
const control = getReplicationSchedulingControl(owner);
control.externalPolling = enabled;
if (enabled) control.disablePeriodic?.();
}
export function markInitialOneShotSatisfied(owner: object): void {
getReplicationSchedulingControl(owner).initialOneShotSatisfied = true;
}