mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-09 12:17:07 +00:00
342 lines
14 KiB
TypeScript
342 lines
14 KiB
TypeScript
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 {
|
|
CAPABILITY_UNAVAILABLE_REASONS,
|
|
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_UNAVAILABLE_REASONS.NOT_APPLICABLE ||
|
|
result.reason === CAPABILITY_UNAVAILABLE_REASONS.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),
|
|
});
|
|
}
|