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
@@ -0,0 +1,339 @@
import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import {
isReplicationCompleted,
NO_INTERACTION,
type ContinuousReplicationRequest,
type ReplicationOutcome,
type UnattendedOneShotRequest,
} from "@vrtmrz/livesync-commonlib/replication";
import { PeriodicProcessor } from "@/common/PeriodicProcessor";
type ReplicationSchedulingSettings = Pick<
ObsidianLiveSyncSettings,
"isConfigured" | "liveSync" | "syncOnStart" | "periodicReplication" | "periodicReplicationInterval"
>;
/** Timer operations required by the scheduling state owner. */
export interface ReplicationSchedulingTimer {
enable(intervalMs: number): void;
disable(): void;
}
/** Daemon-only controls which do not expose mutable scheduling state. */
export interface ReplicationSchedulingControl {
/** Let an external daemon poller become, or cease to be, the recurring-work owner. */
setExternalPollingMode(enabled: boolean): void;
/** Consume the next resume-triggered OneShot because the daemon has already converged once. */
markInitialOneShotSatisfied(): void;
}
interface ReplicationSchedulingDependencies {
isReady(): boolean;
isSuspended(): boolean;
currentSettings(): ReplicationSchedulingSettings;
replicateUnattended(request: UnattendedOneShotRequest): Promise<ReplicationOutcome>;
startContinuous(request: ContinuousReplicationRequest): Promise<ReplicationOutcome>;
timer: ReplicationSchedulingTimer;
log(error: unknown): void;
}
interface ReplicationSchedulingState {
externalPolling: boolean;
continuousOwnsRecurring: boolean;
initialOneShotSatisfied: boolean;
lifecycleAllowsScheduling: boolean;
lifecycleGeneration: number;
resumeOperation: Promise<void> | undefined;
runningResumeGeneration: number | undefined;
queuedResumeGeneration: number | undefined;
}
/** Private state and collaborators owned by the replication scheduling serviceFeature. */
interface ReplicationSchedulingContext {
readonly dependencies: ReplicationSchedulingDependencies;
readonly state: ReplicationSchedulingState;
}
function isCapabilityUnavailable(result: ReplicationOutcome): boolean {
return (
result.status === "blocked" &&
(result.reason === "capability-not-applicable" || result.reason === "capability-not-implemented")
);
}
/** Construct the independently testable context owned by the serviceFeature. */
export function createReplicationSchedulingContext(
dependencies: ReplicationSchedulingDependencies
): ReplicationSchedulingContext {
return {
dependencies,
state: {
externalPolling: false,
continuousOwnsRecurring: false,
initialOneShotSatisfied: false,
// AppLifecycleService does not expose physical visibility as
// isSuspended(). Keep the observed state in this private context.
lifecycleAllowsScheduling: false,
lifecycleGeneration: 0,
resumeOperation: undefined,
runningResumeGeneration: undefined,
queuedResumeGeneration: undefined,
},
};
}
function canRunPeriodic(context: ReplicationSchedulingContext, settings: ReplicationSchedulingSettings): boolean {
const { dependencies, state } = context;
return (
state.lifecycleAllowsScheduling &&
!state.externalPolling &&
!state.continuousOwnsRecurring &&
dependencies.isReady() &&
!dependencies.isSuspended() &&
settings.isConfigured === true &&
settings.periodicReplication === true
);
}
function reconcilePeriodic(context: ReplicationSchedulingContext): void {
const { dependencies } = context;
const settings = dependencies.currentSettings();
if (canRunPeriodic(context, settings)) {
dependencies.timer.enable(settings.periodicReplicationInterval * 1000);
} else {
dependencies.timer.disable();
}
}
function setContinuousOwnership(context: ReplicationSchedulingContext, ownsRecurring: boolean): void {
const { state } = context;
if (state.continuousOwnsRecurring === ownsRecurring) return;
state.continuousOwnsRecurring = ownsRecurring;
reconcilePeriodic(context);
}
function isCurrentLifecycleGeneration(context: ReplicationSchedulingContext, generation: number): boolean {
return generation === context.state.lifecycleGeneration;
}
function canRunResume(context: ReplicationSchedulingContext, generation: number): boolean {
const { dependencies, state } = context;
return (
isCurrentLifecycleGeneration(context, generation) &&
state.lifecycleAllowsScheduling &&
!state.externalPolling &&
dependencies.isReady() &&
!dependencies.isSuspended()
);
}
async function runAfterResume(context: ReplicationSchedulingContext, generation: number): Promise<void> {
if (!canRunResume(context, generation)) return;
const { dependencies, state } = context;
const settings = dependencies.currentSettings();
if (!settings.isConfigured) {
setContinuousOwnership(context, false);
return;
}
const skipOneShot = state.initialOneShotSatisfied;
// This marker belongs to one resume attempt. Consume it before any network
// await so an exceptional Continuous start cannot suppress a later retry.
state.initialOneShotSatisfied = false;
if (settings.liveSync) {
setContinuousOwnership(context, true);
let result: ReplicationOutcome;
try {
result = await dependencies.startContinuous({
trigger: "resume",
interaction: NO_INTERACTION,
});
} catch (error) {
if (isCurrentLifecycleGeneration(context, generation)) {
setContinuousOwnership(context, false);
}
throw error;
}
if (!isReplicationCompleted(result) && isCurrentLifecycleGeneration(context, generation)) {
setContinuousOwnership(context, false);
}
// A suspend/resume may have started a new lifecycle generation while
// Continuous was settling. Do not let the obsolete result schedule a
// finite fallback for the new generation.
if (isCapabilityUnavailable(result) && canRunResume(context, generation)) {
const currentSettings = dependencies.currentSettings();
if (
currentSettings.isConfigured &&
currentSettings.liveSync &&
currentSettings.syncOnStart &&
!skipOneShot
) {
await dependencies.replicateUnattended({
trigger: "resume",
interaction: NO_INTERACTION,
});
}
}
return;
}
setContinuousOwnership(context, false);
if (settings.syncOnStart && !skipOneShot) {
await dependencies.replicateUnattended({
trigger: "resume",
interaction: NO_INTERACTION,
});
}
}
function scheduleAfterResume(context: ReplicationSchedulingContext): void {
const { dependencies, state } = context;
const requestedGeneration = state.lifecycleGeneration;
if (state.resumeOperation) {
// Duplicate notifications within one generation share the current
// operation. A later lifecycle generation must run after it.
if (state.runningResumeGeneration !== requestedGeneration) {
state.queuedResumeGeneration = requestedGeneration;
}
return;
}
state.runningResumeGeneration = requestedGeneration;
state.resumeOperation = runAfterResume(context, requestedGeneration)
.catch((error: unknown) => {
dependencies.log(error);
})
.finally(() => {
state.resumeOperation = undefined;
state.runningResumeGeneration = undefined;
const queuedGeneration = state.queuedResumeGeneration;
state.queuedResumeGeneration = undefined;
if (queuedGeneration === state.lifecycleGeneration && state.lifecycleAllowsScheduling) {
scheduleAfterResume(context);
}
});
}
/** Schedule eligible work after the application has resumed. */
export function resumeReplicationScheduling(context: ReplicationSchedulingContext): void {
const { state } = context;
if (!state.lifecycleAllowsScheduling) {
state.lifecycleGeneration += 1;
}
state.lifecycleAllowsScheduling = true;
// runAfterResume executes synchronously until its first await. A Continuous
// request therefore reserves ownership before Periodic is reconciled.
scheduleAfterResume(context);
reconcilePeriodic(context);
}
/** Stop generic Periodic scheduling before the application suspends. */
export function suspendReplicationScheduling(context: ReplicationSchedulingContext): void {
context.state.lifecycleAllowsScheduling = false;
context.state.queuedResumeGeneration = undefined;
context.dependencies.timer.disable();
}
/** Stop generic Periodic scheduling while settings and provider bindings change. */
export function prepareReplicationSchedulingForSettings(context: ReplicationSchedulingContext): void {
context.dependencies.timer.disable();
}
/** Reconcile generic Periodic scheduling after settings have settled. */
export function realiseReplicationScheduling(context: ReplicationSchedulingContext): void {
reconcilePeriodic(context);
}
/** Prevent later timer callbacks from scheduling new work during unload. */
export function unloadReplicationScheduling(context: ReplicationSchedulingContext): void {
context.state.lifecycleAllowsScheduling = false;
context.state.queuedResumeGeneration = undefined;
context.dependencies.timer.disable();
}
/** Execute one timer callback if Periodic still owns recurring work. */
export async function runPeriodicReplication(context: ReplicationSchedulingContext): Promise<void> {
const { dependencies } = context;
// Clearing an interval does not retract a callback which is already queued.
// Recheck ownership and lifecycle state at execution time.
if (!canRunPeriodic(context, dependencies.currentSettings())) return;
await dependencies.replicateUnattended({
trigger: "periodic",
interaction: NO_INTERACTION,
});
}
/** Declare that an external poller has become, or ceased to be, the recurring-work owner. */
export function setExternalPollingMode(context: ReplicationSchedulingContext, enabled: boolean): void {
if (context.state.externalPolling === enabled) return;
context.state.externalPolling = enabled;
reconcilePeriodic(context);
}
/** Consume the next resume-triggered OneShot because the daemon has already converged once. */
export function markInitialOneShotSatisfied(context: ReplicationSchedulingContext): void {
context.state.initialOneShotSatisfied = true;
}
type ReplicationSchedulingHost = NecessaryServices<
"API" | "appLifecycle" | "control" | "replication" | "setting",
never
>;
type ReplicationSchedulingTimerFactory = (process: () => Promise<void>) => ReplicationSchedulingTimer;
/**
* Compose host lifecycle bindings around one private scheduling context.
*
* The returned view is intentionally limited to daemon scheduling controls.
* @param host Narrow service container used to bind scheduling to the host lifecycle.
* @param createTimer Timer adapter factory, replaceable by focused tests.
* @returns Commands which let the CLI daemon declare its scheduling ownership.
*/
export function useReplicationScheduling(
host: ReplicationSchedulingHost,
createTimer: ReplicationSchedulingTimerFactory = (process) => new PeriodicProcessor(host, process)
): ReplicationSchedulingControl {
const services = host.services;
const log = createInstanceLogFunction("SF:ReplicationScheduling", services.API);
let context!: ReplicationSchedulingContext;
const timer = createTimer(async () => await runPeriodicReplication(context));
context = createReplicationSchedulingContext({
isReady: () => services.appLifecycle.isReady(),
isSuspended: () => services.appLifecycle.isSuspended(),
currentSettings: () => services.setting.currentSettings(),
replicateUnattended: (request) => services.replication.replicateUnattended(request),
startContinuous: (request) => services.replication.startContinuous(request),
timer,
log: (error) => log(error, LOG_LEVEL_VERBOSE),
});
services.appLifecycle.onUnload.addHandler(() => {
unloadReplicationScheduling(context);
return Promise.resolve(true);
});
services.setting.onBeforeRealiseSetting.addHandler(() => {
prepareReplicationSchedulingForSettings(context);
return Promise.resolve(true);
});
services.setting.onSettingRealised.addHandler(() => {
realiseReplicationScheduling(context);
return Promise.resolve(true);
});
services.appLifecycle.onSuspending.addHandler(() => {
suspendReplicationScheduling(context);
return Promise.resolve(true);
});
services.appLifecycle.onResumed.addHandler(() => {
resumeReplicationScheduling(context);
return Promise.resolve(true);
});
return Object.freeze({
setExternalPollingMode: (enabled: boolean) => setExternalPollingMode(context, enabled),
markInitialOneShotSatisfied: () => markInitialOneShotSatisfied(context),
});
}
@@ -0,0 +1,349 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { NO_INTERACTION, type ReplicationOutcome } from "@vrtmrz/livesync-commonlib/replication";
import {
createReplicationSchedulingContext,
markInitialOneShotSatisfied,
resumeReplicationScheduling,
runPeriodicReplication,
setExternalPollingMode,
suspendReplicationScheduling,
useReplicationScheduling,
type ReplicationSchedulingTimer,
} from "./replicationScheduling";
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
function createControllerHarness(
overrides: Partial<{
liveSync: boolean;
syncOnStart: boolean;
periodicReplication: boolean;
periodicReplicationInterval: number;
}> = {}
) {
const settings = {
...DEFAULT_SETTINGS,
isConfigured: true,
liveSync: false,
syncOnStart: true,
periodicReplication: false,
periodicReplicationInterval: 60,
...overrides,
};
const timer: ReplicationSchedulingTimer = {
enable: vi.fn(),
disable: vi.fn(),
};
const replicateUnattended = vi.fn(async (): Promise<ReplicationOutcome> => ({ status: "completed" }));
const startContinuous = vi.fn(async (): Promise<ReplicationOutcome> => ({ status: "completed" }));
const log = vi.fn();
const context = createReplicationSchedulingContext({
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => false),
currentSettings: vi.fn(() => settings),
replicateUnattended,
startContinuous,
timer,
log,
});
return { context, log, replicateUnattended, settings, startContinuous, timer };
}
describe("replication scheduling context", () => {
it("starts a configured unattended OneShot without exposing the operation to the lifecycle handler", async () => {
const { context, replicateUnattended } = createControllerHarness();
expect(resumeReplicationScheduling(context)).toBeUndefined();
await vi.waitFor(() => expect(replicateUnattended).toHaveBeenCalledOnce());
expect(replicateUnattended).toHaveBeenCalledWith({
trigger: "resume",
interaction: NO_INTERACTION,
});
});
it("reserves Continuous ownership before reconciling the periodic timer", async () => {
const timeline: string[] = [];
const continuous = createDeferred<ReplicationOutcome>();
const { context, startContinuous, timer } = createControllerHarness({
liveSync: true,
periodicReplication: true,
});
vi.mocked(timer.disable).mockImplementation(() => {
timeline.push("timer-disabled");
});
startContinuous.mockImplementation(() => {
timeline.push("continuous-started");
return continuous.promise;
});
resumeReplicationScheduling(context);
expect(timeline[0]).toBe("timer-disabled");
expect(timeline).toContain("continuous-started");
expect(timer.enable).not.toHaveBeenCalled();
continuous.resolve({ status: "completed" });
await vi.waitFor(() => expect(startContinuous).toHaveBeenCalledOnce());
});
it("restores Periodic and falls back to OneShot when Continuous is not applicable", async () => {
const { context, replicateUnattended, startContinuous, timer } = createControllerHarness({
liveSync: true,
periodicReplication: true,
periodicReplicationInterval: 45,
});
startContinuous.mockResolvedValue({
status: "blocked",
reason: "capability-not-applicable",
});
resumeReplicationScheduling(context);
await vi.waitFor(() => expect(replicateUnattended).toHaveBeenCalledOnce());
expect(timer.enable).toHaveBeenCalledWith(45_000);
});
it("does not run a finite fallback after Continuous starts successfully", async () => {
const { context, replicateUnattended, startContinuous } = createControllerHarness({ liveSync: true });
resumeReplicationScheduling(context);
await vi.waitFor(() => expect(startContinuous).toHaveBeenCalledOnce());
expect(replicateUnattended).not.toHaveBeenCalled();
});
it("does not run a finite fallback after an actual Continuous failure", async () => {
const { context, replicateUnattended, startContinuous } = createControllerHarness({ liveSync: true });
startContinuous.mockResolvedValue({
status: "failed",
error: new Error("connection failed"),
});
resumeReplicationScheduling(context);
await vi.waitFor(() => expect(startContinuous).toHaveBeenCalledOnce());
expect(replicateUnattended).not.toHaveBeenCalled();
});
it("coalesces concurrent resume notifications", async () => {
const replication = createDeferred<ReplicationOutcome>();
const { context, replicateUnattended } = createControllerHarness();
replicateUnattended.mockImplementation(() => replication.promise);
resumeReplicationScheduling(context);
resumeReplicationScheduling(context);
expect(replicateUnattended).toHaveBeenCalledOnce();
replication.resolve({ status: "completed" });
await vi.waitFor(() => expect(replicateUnattended).toHaveBeenCalledOnce());
});
it("runs a fresh lifecycle generation instead of applying a stale Continuous fallback", async () => {
const firstContinuous = createDeferred<ReplicationOutcome>();
const { context, replicateUnattended, startContinuous, timer } = createControllerHarness({
liveSync: true,
periodicReplication: true,
});
startContinuous
.mockImplementationOnce(() => firstContinuous.promise)
.mockResolvedValueOnce({ status: "completed" });
resumeReplicationScheduling(context);
suspendReplicationScheduling(context);
resumeReplicationScheduling(context);
firstContinuous.resolve({
status: "blocked",
reason: "capability-not-applicable",
});
await vi.waitFor(() => expect(startContinuous).toHaveBeenCalledTimes(2));
expect(replicateUnattended).not.toHaveBeenCalled();
expect(timer.enable).not.toHaveBeenCalled();
});
it("lets the daemon suppress scheduling through the focused control view", async () => {
const { context, replicateUnattended, startContinuous, timer } = createControllerHarness({
liveSync: true,
periodicReplication: true,
});
setExternalPollingMode(context, true);
resumeReplicationScheduling(context);
expect(timer.disable).toHaveBeenCalled();
expect(startContinuous).not.toHaveBeenCalled();
expect(replicateUnattended).not.toHaveBeenCalled();
});
it("consumes the daemon's initial OneShot marker without suppressing a Continuous attempt", async () => {
const { context, replicateUnattended, startContinuous } = createControllerHarness({ liveSync: true });
startContinuous.mockResolvedValue({
status: "blocked",
reason: "capability-not-applicable",
});
markInitialOneShotSatisfied(context);
resumeReplicationScheduling(context);
await vi.waitFor(() => expect(startContinuous).toHaveBeenCalledOnce());
expect(replicateUnattended).not.toHaveBeenCalled();
});
it("consumes the daemon marker even when the first Continuous attempt throws", async () => {
const { context, log, replicateUnattended, startContinuous } = createControllerHarness({ liveSync: true });
startContinuous.mockRejectedValueOnce(new Error("start failed")).mockResolvedValueOnce({
status: "blocked",
reason: "capability-not-applicable",
});
markInitialOneShotSatisfied(context);
resumeReplicationScheduling(context);
await vi.waitFor(() => expect(log).toHaveBeenCalledOnce());
resumeReplicationScheduling(context);
await vi.waitFor(() => expect(startContinuous).toHaveBeenCalledTimes(2));
expect(replicateUnattended).toHaveBeenCalledOnce();
});
it("runs the periodic callback through the unattended replication boundary", async () => {
const { context, replicateUnattended } = createControllerHarness({
syncOnStart: false,
periodicReplication: true,
});
resumeReplicationScheduling(context);
await runPeriodicReplication(context);
expect(replicateUnattended).toHaveBeenCalledWith({
trigger: "periodic",
interaction: NO_INTERACTION,
});
});
it("ignores a queued periodic callback before resume and after suspension", async () => {
const { context, replicateUnattended, timer } = createControllerHarness({
syncOnStart: false,
periodicReplication: true,
});
await runPeriodicReplication(context);
expect(replicateUnattended).not.toHaveBeenCalled();
resumeReplicationScheduling(context);
expect(timer.enable).toHaveBeenCalledWith(60_000);
suspendReplicationScheduling(context);
await runPeriodicReplication(context);
expect(replicateUnattended).not.toHaveBeenCalled();
});
it("ignores a queued periodic callback while external polling owns recurring work", async () => {
const { context, replicateUnattended } = createControllerHarness({
syncOnStart: false,
periodicReplication: true,
});
resumeReplicationScheduling(context);
setExternalPollingMode(context, true);
await runPeriodicReplication(context);
expect(replicateUnattended).not.toHaveBeenCalled();
});
it("ignores a queued periodic callback while Continuous owns recurring work", async () => {
const continuous = createDeferred<ReplicationOutcome>();
const { context, replicateUnattended, startContinuous } = createControllerHarness({
liveSync: true,
syncOnStart: false,
periodicReplication: true,
});
startContinuous.mockImplementation(() => continuous.promise);
resumeReplicationScheduling(context);
await runPeriodicReplication(context);
expect(replicateUnattended).not.toHaveBeenCalled();
continuous.resolve({ status: "completed" });
});
});
describe("replication scheduling serviceFeature", () => {
it("binds lifecycle handlers and exposes only daemon scheduling controls", async () => {
const handlers: Record<string, () => Promise<boolean>> = {};
const timer: ReplicationSchedulingTimer = {
enable: vi.fn(),
disable: vi.fn(),
};
let periodicProcess!: () => Promise<void>;
const replicateUnattended = vi.fn(async (): Promise<ReplicationOutcome> => ({ status: "completed" }));
const addHandler = (name: string) =>
vi.fn((handler: () => Promise<boolean>) => {
handlers[name] = handler;
return () => undefined;
});
const services = {
context: {},
API: { addLog: vi.fn() },
appLifecycle: {
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => false),
onResumed: { addHandler: addHandler("resumed") },
onSuspending: { addHandler: addHandler("suspending") },
onUnload: { addHandler: addHandler("unload") },
},
control: { hasUnloaded: vi.fn(() => false) },
replication: {
replicateUnattended,
startContinuous: vi.fn(async (): Promise<ReplicationOutcome> => ({ status: "completed" })),
},
setting: {
currentSettings: vi.fn(() => ({
...DEFAULT_SETTINGS,
isConfigured: true,
liveSync: false,
syncOnStart: true,
periodicReplication: true,
})),
onBeforeRealiseSetting: { addHandler: addHandler("before-setting") },
onSettingRealised: { addHandler: addHandler("setting-realised") },
},
};
const control = useReplicationScheduling({ services, serviceModules: {} } as never, (process) => {
periodicProcess = process;
return timer;
});
expect(Object.keys(control).sort()).toEqual(["markInitialOneShotSatisfied", "setExternalPollingMode"]);
expect(Object.keys(handlers).sort()).toEqual([
"before-setting",
"resumed",
"setting-realised",
"suspending",
"unload",
]);
await expect(handlers.resumed()).resolves.toBe(true);
await vi.waitFor(() => expect(replicateUnattended).toHaveBeenCalledOnce());
replicateUnattended.mockClear();
await periodicProcess();
expect(replicateUnattended).toHaveBeenCalledWith({
trigger: "periodic",
interaction: NO_INTERACTION,
});
control.setExternalPollingMode(true);
expect(timer.disable).toHaveBeenCalled();
});
});