1
0
silverbullet/web/sync_service.ts

397 lines
12 KiB
TypeScript
Raw Normal View History

2023-08-29 19:17:29 +00:00
import { sleep } from "$sb/lib/async.ts";
import type { SpacePrimitives } from "../common/spaces/space_primitives.ts";
import {
SpaceSync,
SyncStatus,
SyncStatusItem,
} from "../common/spaces/sync.ts";
import { EventHook } from "../plugos/hooks/event.ts";
import { DataStore } from "../plugos/lib/datastore.ts";
2023-08-29 19:17:29 +00:00
import { Space } from "./space.ts";
// Keeps the current sync snapshot
const syncSnapshotKey = ["sync", "snapshot"];
// Keeps the start time of an ongoing sync, is reset once the sync is done
const syncStartTimeKey = ["sync", "startTime"];
2023-08-07 18:42:52 +00:00
// Keeps the start time of the last full sync cycle
const syncLastFullCycleKey = ["sync", "lastFullCycle"];
2023-08-07 18:42:52 +00:00
// Keeps the last time an activity was registered, used to detect if a sync is still alive and whether a new one should be started already
const syncLastActivityKey = ["sync", "lastActivity"];
const syncInitialFullSyncCompletedKey = ["sync", "initialFullSyncCompleted"];
// maximum time between two activities before we consider a sync crashed
const syncMaxIdleTimeout = 1000 * 27;
// How often to sync the whole space
const spaceSyncInterval = 17 * 1000; // Every 17s or so
2023-08-07 18:42:52 +00:00
// Used from Client
export const pageSyncInterval = 6000;
2023-08-29 19:17:29 +00:00
export interface ISyncService {
start(): void;
isSyncing(): Promise<boolean>;
hasInitialSyncCompleted(): Promise<boolean>;
noOngoingSync(_timeout: number): Promise<void>;
syncFile(name: string): Promise<void>;
scheduleFileSync(_path: string): Promise<void>;
scheduleSpaceSync(): Promise<void>;
}
/**
* The SyncService primarily wraps the SpaceSync engine but also coordinates sync between
* different browser tabs. It is using the KVStore to keep track of sync state.
*/
2023-08-29 19:17:29 +00:00
export class SyncService implements ISyncService {
spaceSync: SpaceSync;
lastReportedSyncStatus = Date.now();
constructor(
readonly localSpacePrimitives: SpacePrimitives,
readonly remoteSpace: SpacePrimitives,
private ds: DataStore,
private eventHook: EventHook,
private isSyncCandidate: (path: string) => boolean,
) {
this.spaceSync = new SpaceSync(
this.localSpacePrimitives,
this.remoteSpace!,
{
conflictResolver: this.plugAwareConflictResolver.bind(this),
isSyncCandidate: this.isSyncCandidate,
onSyncProgress: (status) => {
this.registerSyncProgress(status).catch(console.error);
},
},
);
eventHook.addLocalListener(
"editor:pageLoaded",
(name, _prevPage, isSynced) => {
if (!isSynced) {
this.scheduleFileSync(`${name}.md`).catch(console.error);
}
},
);
eventHook.addLocalListener("editor:pageSaved", (name) => {
const path = `${name}.md`;
this.scheduleFileSync(path).catch(console.error);
});
2023-08-29 19:17:29 +00:00
this.spaceSync.on({
fileSynced: (meta, direction) => {
eventHook.dispatchEvent("file:synced", meta, direction);
},
});
}
async isSyncing(): Promise<boolean> {
const startTime = await this.ds.get(syncStartTimeKey);
if (!startTime) {
return false;
}
// Sync is running, but is it still alive?
const lastActivity = await this.ds.get(syncLastActivityKey)!;
if (Date.now() - lastActivity > syncMaxIdleTimeout) {
// It's been too long since the last activity, let's consider this one crashed and
// reset the sync start state
await this.ds.delete(syncStartTimeKey);
2023-08-10 14:09:28 +00:00
console.info("Sync without activity for too long, resetting");
return false;
}
return true;
}
async hasInitialSyncCompleted(): Promise<boolean> {
// Initial sync has happened when sync progress has been reported at least once, but the syncStartTime has been reset (which happens after sync finishes)
return !!(await this.ds.get(syncInitialFullSyncCompletedKey));
}
2023-08-07 18:42:52 +00:00
async registerSyncStart(fullSync: boolean): Promise<void> {
// Assumption: this is called after an isSyncing() check
await this.ds.batchSet([
2023-08-07 18:42:52 +00:00
{
key: syncStartTimeKey,
value: Date.now(),
},
{
key: syncLastActivityKey,
value: Date.now(),
},
...fullSync // If this is a full sync cycle
? [{
key: syncLastFullCycleKey,
value: Date.now(),
}]
: [],
]);
}
async registerSyncProgress(status?: SyncStatus): Promise<void> {
2023-06-14 18:58:08 +00:00
// Emit a sync event at most every 2s
if (status && this.lastReportedSyncStatus < Date.now() - 2000) {
await this.eventHook.dispatchEvent("sync:progress", status);
this.lastReportedSyncStatus = Date.now();
await this.saveSnapshot(status.snapshot);
}
await this.ds.set(syncLastActivityKey, Date.now());
}
2023-08-10 14:09:28 +00:00
async registerSyncStop(isFullSync: boolean): Promise<void> {
await this.registerSyncProgress();
await this.ds.delete(syncStartTimeKey);
2023-08-10 14:09:28 +00:00
if (isFullSync) {
await this.ds.set(syncInitialFullSyncCompletedKey, true);
2023-08-10 14:09:28 +00:00
}
}
async getSnapshot(): Promise<Map<string, SyncStatusItem>> {
const snapshot = (await this.ds.get(syncSnapshotKey)) || {};
return new Map<string, SyncStatusItem>(
Object.entries(snapshot),
);
}
// Await a moment when the sync is no longer running
2023-08-08 15:19:43 +00:00
async noOngoingSync(timeout: number): Promise<void> {
// Not completely safe, could have race condition on setting the syncStartTimeKey
2023-08-08 15:19:43 +00:00
const startTime = Date.now();
while (await this.isSyncing()) {
2023-08-09 15:52:39 +00:00
console.log("Waiting for ongoing sync to finish...");
await sleep(321);
2023-08-08 15:19:43 +00:00
if (Date.now() - startTime > timeout) {
throw new Error("Timeout waiting for sync to finish");
}
}
}
filesScheduledForSync = new Set<string>();
async scheduleFileSync(path: string): Promise<void> {
if (this.filesScheduledForSync.has(path)) {
// Already scheduled, no need to duplicate
console.info(`File ${path} already scheduled for sync`);
return;
}
this.filesScheduledForSync.add(path);
await this.noOngoingSync(7000);
await this.syncFile(path);
this.filesScheduledForSync.delete(path);
}
2023-08-15 18:24:02 +00:00
async scheduleSpaceSync(): Promise<void> {
await this.noOngoingSync(5000);
await this.syncSpace();
}
start() {
this.syncSpace().catch(console.error);
setInterval(async () => {
try {
2023-08-07 18:42:52 +00:00
if (!await this.isSyncing()) {
const lastFullCycle = (await this.ds.get(syncLastFullCycleKey)) || 0;
2023-08-07 18:42:52 +00:00
if (lastFullCycle && Date.now() - lastFullCycle > spaceSyncInterval) {
// It's been a while since the last full cycle, let's sync the whole space
await this.syncSpace();
}
}
} catch (e: any) {
console.error(e);
}
2023-08-07 18:42:52 +00:00
}, spaceSyncInterval / 2); // check every half the sync cycle because actually running the sync takes some time therefore we don't want to wait for the full cycle
}
async syncSpace(): Promise<number> {
if (await this.isSyncing()) {
2023-08-08 15:19:43 +00:00
console.log("Aborting space sync: already syncing");
return 0;
}
2023-08-07 18:42:52 +00:00
await this.registerSyncStart(true);
let operations = 0;
const snapshot = await this.getSnapshot();
// console.log("Excluded from sync", excludedFromSync);
try {
operations = await this.spaceSync!.syncFiles(
snapshot,
(path) => this.isSyncCandidate(path),
);
await this.saveSnapshot(snapshot);
2023-08-10 14:09:28 +00:00
await this.registerSyncStop(true);
await this.eventHook.dispatchEvent("sync:success", operations);
} catch (e: any) {
await this.saveSnapshot(snapshot);
2023-08-10 14:09:28 +00:00
await this.registerSyncStop(false);
await this.eventHook.dispatchEvent("sync:error", e.message);
2023-07-28 11:54:44 +00:00
console.error("Sync error", e.message);
}
return operations;
}
// Syncs a single file
async syncFile(name: string) {
2023-08-10 14:09:28 +00:00
// console.log("Checking if we can sync file", name);
if (!this.isSyncCandidate(name)) {
2023-08-10 14:09:28 +00:00
console.info("Requested sync, but not a sync candidate", name);
return;
}
if (await this.isSyncing()) {
console.log("Already syncing, aborting individual file sync for", name);
return;
}
console.log("Syncing file", name);
2023-08-09 15:52:39 +00:00
await this.registerSyncStart(false);
const snapshot = await this.getSnapshot();
try {
let localHash: number | undefined;
let remoteHash: number | undefined;
try {
const localMeta = await this.localSpacePrimitives.getFileMeta(name);
2023-07-30 06:56:44 +00:00
if (localMeta.noSync) {
console.info(
2023-07-30 06:56:44 +00:00
"File marked as no sync, skipping sync in this cycle",
name,
);
2023-08-10 14:09:28 +00:00
await this.registerSyncStop(false);
// Jumping out, not saving snapshot nor triggering a sync event, because we did nothing
return;
}
localHash = localMeta.lastModified;
} catch {
// Not present
}
try {
2023-08-08 13:00:18 +00:00
remoteHash = (await this.remoteSpace!.getFileMeta(name)).lastModified;
WIP: Plug API document change event (#488) * add support for basic on doc change event * move change API core into plug-api/lib; add docs * add overlap utility * Maintain modal focus * Federated URL backend handling * Fix small typo in Query.md (#483) * Federation progress * Cleanup and federation prep * Robustness and federation sync * Federation: rewrite page references in federated content * Don't sync service worker and index.json to client on silverbullet.md * Federation listing timeouts * Switching onboarding over to federation links * Reduce amount of sync related log messages a bit * Attribute indexing and code completion * Shift-Enter in the page navigator now takes the input literally * Updated changelog * Completion for handlebar template variables * Make 'pos' a number in tasks * Updated install instructions to include edge builds * WIP: CLI running of plugs * Upgrade deno in Docker to 1.36.0 * Implement CLI store using Deno store * Rerun directives * Fixes #485 * 0.3.8 * 0.3.9 * Changelog * Instantly sync updated pages when ticking off a task in a directive * Sync current open page every 5s * Optimize requests * Make attribute extensible * Debugging sync getting stuck * Misaligning sync cycles (to avoid no-op cycles) * Fixes #500: New apply page template command * Changelog * More sync debugging statements * More sync debugging * Even more debug * Dial down excessive debug logging * Fixes #115: By introducing MQ workers * Use MQ for updating directives in entire space * Work on plug:run * touch up docs * Fix htmlLanguage dependency --------- Co-authored-by: Zef Hemel <zef@zef.me> Co-authored-by: johnl <johnlunney@users.noreply.github.com>
2023-08-16 13:15:19 +00:00
// HEAD
//
2023-08-15 18:15:27 +00:00
if (!remoteHash) {
console.info(
"Not syncing file, because remote didn't send X-Last-Modified header",
);
// This happens when the remote isn't a real SilverBullet server, specifically: it's not sending
// a X-Last-Modified header. In this case we'll just assume that the file is up to date.
await this.registerSyncStop(false);
// Jumping out, not saving snapshot nor triggering a sync event, because we did nothing
return;
}
WIP: Plug API document change event (#488) * add support for basic on doc change event * move change API core into plug-api/lib; add docs * add overlap utility * Maintain modal focus * Federated URL backend handling * Fix small typo in Query.md (#483) * Federation progress * Cleanup and federation prep * Robustness and federation sync * Federation: rewrite page references in federated content * Don't sync service worker and index.json to client on silverbullet.md * Federation listing timeouts * Switching onboarding over to federation links * Reduce amount of sync related log messages a bit * Attribute indexing and code completion * Shift-Enter in the page navigator now takes the input literally * Updated changelog * Completion for handlebar template variables * Make 'pos' a number in tasks * Updated install instructions to include edge builds * WIP: CLI running of plugs * Upgrade deno in Docker to 1.36.0 * Implement CLI store using Deno store * Rerun directives * Fixes #485 * 0.3.8 * 0.3.9 * Changelog * Instantly sync updated pages when ticking off a task in a directive * Sync current open page every 5s * Optimize requests * Make attribute extensible * Debugging sync getting stuck * Misaligning sync cycles (to avoid no-op cycles) * Fixes #500: New apply page template command * Changelog * More sync debugging statements * More sync debugging * Even more debug * Dial down excessive debug logging * Fixes #115: By introducing MQ workers * Use MQ for updating directives in entire space * Work on plug:run * touch up docs * Fix htmlLanguage dependency --------- Co-authored-by: Zef Hemel <zef@zef.me> Co-authored-by: johnl <johnlunney@users.noreply.github.com>
2023-08-16 13:15:19 +00:00
//main
} catch (e: any) {
if (e.message === "Not found") {
// File doesn't exist remotely, that's ok
} else {
throw e;
}
}
await this.spaceSync.syncFile(snapshot, name, localHash, remoteHash);
2023-08-10 14:09:28 +00:00
this.eventHook.dispatchEvent("sync:success").catch(console.error);
// console.log("File successfully synced", name);
} catch (e: any) {
2023-08-10 14:09:28 +00:00
this.eventHook.dispatchEvent("sync:error", e.message).catch(
console.error,
);
console.error("Sync error", e);
}
await this.saveSnapshot(snapshot);
2023-08-10 14:09:28 +00:00
await this.registerSyncStop(false);
}
async saveSnapshot(snapshot: Map<string, SyncStatusItem>) {
await this.ds.set(syncSnapshotKey, Object.fromEntries(snapshot));
}
public async plugAwareConflictResolver(
name: string,
snapshot: Map<string, SyncStatusItem>,
primary: SpacePrimitives,
secondary: SpacePrimitives,
): Promise<number> {
if (!name.startsWith("_plug/")) {
const operations = await SpaceSync.primaryConflictResolver(
name,
snapshot,
primary,
secondary,
);
if (operations > 0) {
// Something happened -> conflict copy generated, let's report it
await this.eventHook.dispatchEvent("sync:conflict", name);
}
return operations;
}
console.log(
"[sync]",
"Conflict in plug",
name,
"will pick the version from secondary and be done with it.",
);
// Read file from secondary
const { data, meta } = await secondary.readFile(
name,
);
// Write file to primary
const newMeta = await primary.writeFile(
name,
data,
false,
meta,
);
// Update snapshot
snapshot.set(name, [
newMeta.lastModified,
meta.lastModified,
]);
return 1;
}
}
2023-08-29 19:17:29 +00:00
/**
* A no-op sync service that doesn't do anything used when running in thin client mode
*/
export class NoSyncSyncService implements ISyncService {
constructor(private space: Space) {
}
isSyncing(): Promise<boolean> {
return Promise.resolve(false);
}
hasInitialSyncCompleted(): Promise<boolean> {
return Promise.resolve(true);
}
noOngoingSync(_timeout: number): Promise<void> {
return Promise.resolve();
}
scheduleFileSync(_path: string): Promise<void> {
return Promise.resolve();
}
scheduleSpaceSync(): Promise<void> {
return Promise.resolve();
}
start() {
setInterval(() => {
// Trigger a page upload for change events
this.space.updatePageList().catch(console.error);
}, spaceSyncInterval);
}
syncSpace(): Promise<number> {
return Promise.resolve(0);
}
syncFile(_name: string): Promise<void> {
return Promise.resolve();
}
}