Add WebSocket support (#12)

This commit is contained in:
Andras Schmelczer 2025-03-29 10:17:46 +00:00 committed by GitHub
parent 3d27b7f313
commit 1aad0fce31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 2578 additions and 993 deletions

View file

@ -1,65 +0,0 @@
import type { Logger } from "../tracing/logger";
import type { RelativePath } from "../persistence/database";
// Manages locks on documents to prevent concurrent modifications
// allowing the client's FileOperations implementation to be simpler.
// Locks are granted in a first-in-first-out order.
export class DocumentLocks {
private readonly locked = new Set<RelativePath>();
private readonly waiters = new Map<RelativePath, (() => void)[]>();
public constructor(private readonly logger: Logger) {}
public tryLockDocument(relativePath: RelativePath): boolean {
if (this.locked.has(relativePath)) {
return false;
}
this.locked.add(relativePath);
return true;
}
public async waitForDocumentLock(
relativePath: RelativePath
): Promise<void> {
if (this.tryLockDocument(relativePath)) {
return Promise.resolve();
}
this.logger.debug(`Waiting for lock on ${relativePath}`);
return new Promise((resolve) => {
let waiting = this.waiters.get(relativePath);
if (!waiting) {
waiting = [];
this.waiters.set(relativePath, waiting);
}
waiting.push(resolve);
});
}
public unlockDocument(relativePath: RelativePath): void {
if (!this.locked.has(relativePath)) {
throw new Error(
`Document ${relativePath} is not locked, cannot unlock`
);
}
// Remove the first element to ensure FIFO unblocking order
const nextWaiting = this.waiters.get(relativePath)?.shift();
if (nextWaiting) {
this.logger.debug(`Granted lock on ${relativePath}`);
nextWaiting();
} else {
this.locked.delete(relativePath);
}
}
public reset(): void {
this.locked.clear();
this.waiters.clear();
}
}

View file

@ -1,7 +1,7 @@
import type { RelativePath } from "../persistence/database";
import type { FileSystemOperations } from "./filesystem-operations";
import type { Logger } from "../tracing/logger";
import { DocumentLocks } from "./document-locks";
import { Locks } from "../utils/locks";
import { FileNotFoundError } from "./file-not-found-error";
/**
@ -10,13 +10,13 @@ import { FileNotFoundError } from "./file-not-found-error";
* single request in-flight for any one file through the use of locks.
*/
export class SafeFileSystemOperations implements FileSystemOperations {
private readonly locks: DocumentLocks;
private readonly locks: Locks<RelativePath>;
public constructor(
private readonly fs: FileSystemOperations,
private readonly logger: Logger
) {
this.locks = new DocumentLocks(logger);
this.locks = new Locks(logger);
}
public async listAllFiles(): Promise<RelativePath[]> {
@ -117,7 +117,7 @@ export class SafeFileSystemOperations implements FileSystemOperations {
: [pathOrPaths];
await Promise.all(
paths.map(async (path) => this.locks.waitForDocumentLock(path))
paths.map(async (path) => this.locks.waitForLock(path))
);
try {
@ -125,7 +125,7 @@ export class SafeFileSystemOperations implements FileSystemOperations {
} finally {
await Promise.all(
paths.map((path) => {
this.locks.unlockDocument(path);
this.locks.unlock(path);
})
);
}

View file

@ -5,10 +5,10 @@ export {
type HistoryEntry
} from "./tracing/sync-history";
export { Logger, LogLevel, LogLine } from "./tracing/logger";
export type { CheckConnectionResult } from "./services/sync-service";
export { type SyncSettings } from "./persistence/settings";
export type { RelativePath, StoredDatabase } from "./persistence/database";
export type { FileSystemOperations } from "./file-operations/filesystem-operations";
export type { PersistenceProvider } from "./persistence/persistence";
export type { NetworkConnectionStatus } from "./sync-client";
export { SyncClient } from "./sync-client";

View file

@ -1,11 +1,9 @@
import type { Logger } from "../tracing/logger";
import { LogLevel } from "../tracing/logger";
export interface SyncSettings {
remoteUri: string;
token: string;
vaultName: string;
fetchChangesUpdateIntervalMs: number;
syncConcurrency: number;
isSyncEnabled: boolean;
maxFileSizeMB: number;
@ -15,7 +13,6 @@ const DEFAULT_SETTINGS: SyncSettings = {
remoteUri: "",
token: "",
vaultName: "default",
fetchChangesUpdateIntervalMs: 1000,
syncConcurrency: 1,
isSyncEnabled: false,
maxFileSizeMB: 10

View file

@ -18,6 +18,7 @@ export interface CheckConnectionResult {
}
export class SyncService {
private static readonly NETWORK_RETRY_INTERVAL_MS = 1000;
private client: Client<paths>;
private pingClient: Client<paths>;
private _fetchImplementation: typeof globalThis.fetch = globalThis.fetch;
@ -284,17 +285,35 @@ export class SyncService {
public async checkConnection(): Promise<CheckConnectionResult> {
try {
const result = await this.ping();
const response = await this.pingClient.GET("/ping", {
params: {
header: {
authorization: `Bearer ${this.settings.getSettings().token}`
}
}
});
this.logger.debug(
`Ping response: ${JSON.stringify(response.data)}`
);
if (!response.data) {
throw new Error(
`Failed to ping server: ${SyncService.formatError(response.error)}`
);
}
const result = response.data;
if (result.isAuthenticated) {
return {
isSuccessful: true,
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated.`
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
};
}
return {
isSuccessful: false,
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate.`
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
};
} catch (e) {
return {
@ -304,27 +323,6 @@ export class SyncService {
}
}
// No retries
private async ping(): Promise<components["schemas"]["PingResponse"]> {
const response = await this.pingClient.GET("/ping", {
params: {
header: {
authorization: `Bearer ${this.settings.getSettings().token}`
}
}
});
this.logger.debug(`Ping response: ${JSON.stringify(response.data)}`);
if (!response.data) {
throw new Error(
`Failed to ping server: ${SyncService.formatError(response.error)}`
);
}
return response.data;
}
/**
* Create a client and a ping client for the given remote URI.
*/
@ -355,8 +353,10 @@ export class SyncService {
throw e;
}
this.logger.error(`Failed network call (${e}), retrying`);
await sleep(1000);
this.logger.error(
`Failed network call (${e}), retryingin ${SyncService.NETWORK_RETRY_INTERVAL_MS}ms`
);
await sleep(SyncService.NETWORK_RETRY_INTERVAL_MS);
}
}
}

View file

@ -566,6 +566,10 @@ export interface components {
/** Format: int64 */
since_update_id?: number | null;
};
QueryParams2: {
/** Format: int64 */
since_update_id?: number | null;
};
SerializedError: {
causes: string[];
message: string;
@ -587,6 +591,9 @@ export interface components {
parentVersionId: number;
relativePath: string;
};
WebsocketPathParams: {
vault_id: string;
};
};
responses: never;
parameters: never;

View file

@ -8,7 +8,6 @@ import type { RelativePath, StoredDatabase } from "./persistence/database";
import { Database } from "./persistence/database";
import type { SyncSettings } from "./persistence/settings";
import { Settings } from "./persistence/settings";
import type { CheckConnectionResult } from "./services/sync-service";
import { SyncService } from "./services/sync-service";
import { Syncer } from "./sync-operations/syncer";
import type { FileSystemOperations } from "./file-operations/filesystem-operations";
@ -16,9 +15,13 @@ import { FileOperations } from "./file-operations/file-operations";
import { ConnectionStatus } from "./services/connection-status";
import { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer";
export class SyncClient {
private remoteListenerIntervalId: NodeJS.Timeout | null = null;
export interface NetworkConnectionStatus {
isSuccessful: boolean;
serverMessage: string;
isWebSocketConnected: boolean;
}
export class SyncClient {
// eslint-disable-next-line @typescript-eslint/max-params
private constructor(
private readonly history: SyncHistory,
@ -31,15 +34,6 @@ export class SyncClient {
) {
this.settings.addOnSettingsChangeListener(
(newSettings, oldSettings) => {
if (
newSettings.fetchChangesUpdateIntervalMs !==
oldSettings.fetchChangesUpdateIntervalMs
) {
this.setRemoteEventListener(
newSettings.fetchChangesUpdateIntervalMs
);
}
if (newSettings.vaultName !== oldSettings.vaultName) {
void this.reset();
}
@ -145,8 +139,13 @@ export class SyncClient {
return client;
}
public async checkConnection(): Promise<CheckConnectionResult> {
return this.syncService.checkConnection();
public async checkConnection(): Promise<NetworkConnectionStatus> {
const server = await this.syncService.checkConnection();
return {
isSuccessful: server.isSuccessful,
serverMessage: server.message,
isWebSocketConnected: this.syncer.isWebSocketConnected
};
}
public getHistoryEntries(): readonly HistoryEntry[] {
@ -161,20 +160,15 @@ export class SyncClient {
public async start(): Promise<void> {
await this.syncer.scheduleSyncForOfflineChanges();
this.setRemoteEventListener(
this.settings.getSettings().fetchChangesUpdateIntervalMs
);
}
/// Clear all global state that has been touched by SyncClient.
public stop(): void {
this.unsetRemoteEventListener();
this.syncer.stop();
}
public async waitAndStop(): Promise<void> {
await this.syncer.waitUntilFinished();
this.stop();
await this.syncer.waitUntilFinished();
}
/// Wait for the in-flight operations to finish, reset all tracking,
@ -218,6 +212,10 @@ export class SyncClient {
this.syncer.addRemainingOperationsListener(listener);
}
public addWebSocketStatusChangeListener(listener: () => void): void {
this.syncer.addWebSocketStatusChangeListener(listener);
}
public async syncLocallyCreatedFile(
relativePath: RelativePath
): Promise<void> {
@ -242,21 +240,4 @@ export class SyncClient {
relativePath
});
}
private setRemoteEventListener(intervalMs: number): void {
if (this.remoteListenerIntervalId !== null) {
clearInterval(this.remoteListenerIntervalId);
}
this.remoteListenerIntervalId = setInterval(
() => void this.syncer.applyRemoteChangesLocally(),
intervalMs
);
}
private unsetRemoteEventListener(): void {
if (this.remoteListenerIntervalId !== null) {
clearInterval(this.remoteListenerIntervalId);
}
}
}

View file

@ -1,30 +1,40 @@
import type { Database, RelativePath } from "../persistence/database";
import type {
Database,
DocumentId,
RelativePath
} from "../persistence/database";
import type { SyncService } from "../services/sync-service";
import type { Logger } from "../tracing/logger";
import PQueue from "p-queue";
import { hash } from "../utils/hash";
import { v4 as uuidv4 } from "uuid";
import type { components } from "../services/types";
import type { Settings } from "../persistence/settings";
import type { Settings, SyncSettings } from "../persistence/settings";
import type { FileOperations } from "../file-operations/file-operations";
import { findMatchingFile } from "../utils/find-matching-file";
import type { UnrestrictedSyncer } from "./unrestricted-syncer";
import { createPromise } from "../utils/create-promise";
import { SyncResetError } from "../services/sync-reset-error";
import { Locks } from "../utils/locks";
export class Syncer {
private readonly remoteDocumentsLock: Locks<DocumentId>;
private readonly remainingOperationsListeners: ((
remainingOperations: number
) => void)[] = [];
private readonly webSocketStatusChangeListeners: (() => void)[] = [];
private readonly syncQueue: PQueue;
private runningScheduleSyncForOfflineChanges: Promise<void> | undefined;
private runningApplyRemoteChangesLocally: Promise<void> | undefined;
private refreshApplyRemoteChangesWebSocketInterval:
| NodeJS.Timeout
| undefined;
private applyRemoteChangesWebSocket: WebSocket | undefined;
public constructor(
private readonly logger: Logger,
private readonly database: Database,
settings: Settings,
private readonly settings: Settings,
private readonly syncService: SyncService,
private readonly operations: FileOperations,
private readonly internalSyncer: UnrestrictedSyncer
@ -33,11 +43,23 @@ export class Syncer {
concurrency: settings.getSettings().syncConcurrency
});
this.updateWebSocket(settings.getSettings());
this.remoteDocumentsLock = new Locks<DocumentId>(this.logger);
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
if (newSettings.syncConcurrency === oldSettings.syncConcurrency) {
return;
if (
newSettings.remoteUri !== oldSettings.remoteUri ||
newSettings.vaultName !== oldSettings.vaultName ||
newSettings.token !== oldSettings.token ||
newSettings.isSyncEnabled !== oldSettings.isSyncEnabled
) {
this.updateWebSocket(newSettings);
}
if (newSettings.syncConcurrency !== oldSettings.syncConcurrency) {
this.syncQueue.concurrency = newSettings.syncConcurrency;
}
this.syncQueue.concurrency = newSettings.syncConcurrency;
});
this.syncQueue.on("active", () => {
@ -45,6 +67,12 @@ export class Syncer {
listener(this.syncQueue.size);
});
});
this.setWebSocketRefreshInterval();
}
public get isWebSocketConnected(): boolean {
return this.applyRemoteChangesWebSocket?.readyState === WebSocket.OPEN;
}
public addRemainingOperationsListener(
@ -53,6 +81,10 @@ export class Syncer {
this.remainingOperationsListeners.push(listener);
}
public addWebSocketStatusChangeListener(listener: () => void): void {
this.webSocketStatusChangeListeners.push(listener);
}
public async syncLocallyCreatedFile(
relativePath: RelativePath
): Promise<void> {
@ -206,109 +238,139 @@ export class Syncer {
}
}
public async applyRemoteChangesLocally(): Promise<void> {
if (this.runningApplyRemoteChangesLocally !== undefined) {
this.logger.debug(
"Applying remote changes locally is already in progress"
);
return this.runningApplyRemoteChangesLocally;
}
try {
this.runningApplyRemoteChangesLocally =
this.internalApplyRemoteChangesLocally();
await this.runningApplyRemoteChangesLocally;
this.logger.info("All remote changes have been applied locally");
} catch (e) {
if (e instanceof SyncResetError) {
this.logger.info(
"Failed to apply remote changes locally due to a reset"
);
return;
}
this.logger.error(`Failed to apply remote changes locally: ${e}`);
throw e;
} finally {
this.runningApplyRemoteChangesLocally = undefined;
}
public async waitUntilFinished(): Promise<void> {
await this.runningScheduleSyncForOfflineChanges;
return this.syncQueue.onEmpty();
}
public async reset(): Promise<void> {
await this.waitUntilFinished();
this.internalSyncer.reset();
this.setWebSocketRefreshInterval();
this.updateWebSocket(this.settings.getSettings());
}
public async waitUntilFinished(): Promise<void> {
await Promise.allSettled([
this.runningScheduleSyncForOfflineChanges,
this.runningApplyRemoteChangesLocally
]);
return this.syncQueue.onEmpty();
public stop(): void {
clearInterval(this.refreshApplyRemoteChangesWebSocketInterval);
this.applyRemoteChangesWebSocket?.close();
}
private async internalApplyRemoteChangesLocally(): Promise<void> {
const remote = await this.syncQueue.add(async () =>
this.syncService.getAll(this.database.getLastSeenUpdateId())
);
private updateWebSocket(settings: SyncSettings): void {
this.applyRemoteChangesWebSocket?.close();
if (!remote) {
throw new Error("Failed to fetch remote changes");
}
if (remote.latestDocuments.length === 0) {
this.logger.debug("No remote changes to apply");
if (!settings.isSyncEnabled) {
this.applyRemoteChangesWebSocket = undefined;
return;
}
this.logger.info("Applying remote changes locally");
const wsUri = new URL(settings.remoteUri);
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
wsUri.pathname = `/vaults/${settings.vaultName}/ws`;
await Promise.all(
remote.latestDocuments.map(this.syncRemotelyUpdatedFile.bind(this))
);
const lastSeenUpdateId = this.database.getLastSeenUpdateId();
if (
lastSeenUpdateId === undefined ||
lastSeenUpdateId < remote.lastUpdateId
typeof globalThis !== "undefined" &&
typeof globalThis.WebSocket === "undefined"
) {
this.database.setLastSeenUpdateId(remote.lastUpdateId);
// polyfill for WebSocket in Node.js
// eslint-disable-next-line
globalThis.WebSocket = require("ws");
}
this.applyRemoteChangesWebSocket = new WebSocket(wsUri);
this.applyRemoteChangesWebSocket.onmessage = (event): void =>
void this.syncRemotelyUpdatedFile(event.data).catch(
(e: unknown) => {
this.logger.error(
`Failed to sync remotely updated file: ${e}`
);
}
);
// The JS WebSocket API doesn't support setting headers, so we have to send the token as a message
this.applyRemoteChangesWebSocket.onopen = (): void => {
this.applyRemoteChangesWebSocket?.send(settings.token);
this.webSocketStatusChangeListeners.forEach((listener) => {
listener();
});
};
this.applyRemoteChangesWebSocket.onclose = (event): void => {
this.logger.warn(
`WebSocket closed with code ${event.code}: ${event.reason}`
);
this.webSocketStatusChangeListeners.forEach((listener) => {
listener();
});
};
}
private async syncRemotelyUpdatedFile(
remoteVersion: components["schemas"]["DocumentVersionWithoutContent"]
): Promise<void> {
private setWebSocketRefreshInterval(): void {
this.refreshApplyRemoteChangesWebSocketInterval = setInterval(() => {
if (
this.applyRemoteChangesWebSocket?.readyState === WebSocket.OPEN
) {
return;
}
this.updateWebSocket(this.settings.getSettings());
}, 5000);
}
private async syncRemotelyUpdatedFile(message: string): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const remoteVersion = JSON.parse(
message
) as components["schemas"]["DocumentVersionWithoutContent"];
let document = this.database.getDocumentByDocumentId(
remoteVersion.documentId
);
const [promise, resolve, reject] = createPromise();
let hasLockToRelease = false;
if (document === undefined) {
await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile(
remoteVersion
)
// Let's avoid the same documents getting created in parallel multiple times
await this.remoteDocumentsLock.waitForLock(
remoteVersion.documentId
);
} else {
document = await this.database.getResolvedDocumentByRelativePath(
document.relativePath,
promise
hasLockToRelease = true;
document = this.database.getDocumentByDocumentId(
remoteVersion.documentId
);
}
try {
try {
if (document === undefined) {
await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile(
remoteVersion,
document
remoteVersion
)
);
} else {
const [promise, resolve, reject] = createPromise();
resolve();
} catch (e) {
reject(e);
} finally {
this.database.removeDocumentPromise(promise);
document =
await this.database.getResolvedDocumentByRelativePath(
document.relativePath,
promise
);
try {
await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile(
remoteVersion,
document
)
);
resolve();
} catch (e) {
reject(e);
} finally {
this.database.removeDocumentPromise(promise);
}
}
} finally {
if (hasLockToRelease) {
this.remoteDocumentsLock.unlock(remoteVersion.documentId);
}
}
}

View file

@ -13,14 +13,11 @@ import type { components } from "../services/types";
import { deserialize } from "../utils/deserialize";
import type { Settings } from "../persistence/settings";
import type { FileOperations } from "../file-operations/file-operations";
import { DocumentLocks } from "../file-operations/document-locks";
import { createPromise } from "../utils/create-promise";
import { FileNotFoundError } from "../file-operations/file-not-found-error";
import { SyncResetError } from "../services/sync-reset-error";
export class UnrestrictedSyncer {
private readonly locks: DocumentLocks;
public constructor(
private readonly logger: Logger,
private readonly database: Database,
@ -28,10 +25,7 @@ export class UnrestrictedSyncer {
private readonly syncService: SyncService,
private readonly operations: FileOperations,
private readonly history: SyncHistory
) {
this.locks = new DocumentLocks(logger);
}
) {}
public async unrestrictedSyncLocallyCreatedFile(
document: DocumentRecord
): Promise<void> {
@ -416,10 +410,6 @@ export class UnrestrictedSyncer {
}
}
public reset(): void {
this.locks.reset();
}
private tryIncrementVaultUpdateId(responseVaultUpdateId: number): void {
if (this.database.getLastSeenUpdateId() === responseVaultUpdateId - 1) {
this.database.setLastSeenUpdateId(responseVaultUpdateId);

View file

@ -21,7 +21,7 @@ export class LogLine {
}
export class Logger {
private static readonly MAX_MESSAGES = 2000;
private static readonly MAX_MESSAGES = 100000;
private readonly messages: LogLine[] = [];
private readonly onMessageListeners: ((message: LogLine) => void)[] = [];

View file

@ -1,92 +1,88 @@
import { Logger } from "../tracing/logger";
import type { RelativePath } from "../persistence/database";
import { DocumentLocks } from "./document-locks";
import { Locks } from "./locks";
describe("Document lock", () => {
const testPath: RelativePath = "test/document/path";
const logger = new Logger();
let locks = new DocumentLocks(logger);
// eslint-disable-next-line @typescript-eslint/init-declarations
let locks: Locks<RelativePath>;
beforeEach(() => {
locks = new DocumentLocks(logger);
locks = new Locks<RelativePath>(logger);
});
test("should lock a document successfully", () => {
const result = locks.tryLockDocument(testPath);
const result = locks.tryLock(testPath);
expect(result).toBe(true);
});
test("should not lock a document that is already locked", () => {
locks.tryLockDocument(testPath);
const result = locks.tryLockDocument(testPath);
locks.tryLock(testPath);
const result = locks.tryLock(testPath);
expect(result).toBe(false);
});
test("should unlock a locked document", () => {
locks.tryLockDocument(testPath);
locks.unlockDocument(testPath);
const result = locks.tryLockDocument(testPath);
locks.tryLock(testPath);
locks.unlock(testPath);
const result = locks.tryLock(testPath);
expect(result).toBe(true);
locks.unlockDocument(testPath);
locks.unlock(testPath);
});
test("should throw an error when unlocking a document that is not locked", () => {
expect(() => {
locks.unlockDocument(testPath);
locks.unlock(testPath);
}).toThrow(`Document ${testPath} is not locked, cannot unlock`);
});
test("should wait for a document lock and resolve when unlocked", async () => {
locks.tryLockDocument(testPath);
locks.tryLock(testPath);
let resolved = false;
const waitPromise = locks.waitForDocumentLock(testPath).then(() => {
const waitPromise = locks.waitForLock(testPath).then(() => {
resolved = true;
});
locks.unlockDocument(testPath);
locks.unlock(testPath);
await waitPromise;
expect(resolved).toBe(true);
});
test("should resolve multiple waiters in FIFO order", async () => {
locks.tryLockDocument(testPath);
locks.tryLock(testPath);
let firstResolved = false;
let secondResolved = false;
let thirdResolved = false;
const firstWaitPromise = locks
.waitForDocumentLock(testPath)
.then(() => {
firstResolved = true;
});
const firstWaitPromise = locks.waitForLock(testPath).then(() => {
firstResolved = true;
});
const secondWaitPromise = locks
.waitForDocumentLock(testPath)
.then(() => {
secondResolved = true;
});
const secondWaitPromise = locks.waitForLock(testPath).then(() => {
secondResolved = true;
});
const thirdWaitPromise = locks
.waitForDocumentLock(testPath)
.then(() => {
thirdResolved = true;
});
const thirdWaitPromise = locks.waitForLock(testPath).then(() => {
thirdResolved = true;
});
locks.unlockDocument(testPath);
locks.unlock(testPath);
await firstWaitPromise;
expect(firstResolved).toBe(true);
expect(secondResolved).toBe(false);
expect(thirdResolved).toBe(false);
locks.unlockDocument(testPath);
locks.unlock(testPath);
await secondWaitPromise;
expect(secondResolved).toBe(true);
expect(thirdResolved).toBe(false);
locks.unlockDocument(testPath);
locks.unlock(testPath);
await thirdWaitPromise;
expect(thirdResolved).toBe(true);
});

View file

@ -0,0 +1,60 @@
import type { Logger } from "../tracing/logger";
// Manages locks on T to prevent concurrent modifications
// allowing the client's FileOperations implementation to be simpler.
// Locks are granted in a first-in-first-out order.
export class Locks<T> {
private readonly locked = new Set<T>();
private readonly waiters = new Map<T, (() => void)[]>();
public constructor(private readonly logger: Logger) {}
public tryLock(key: T): boolean {
if (this.locked.has(key)) {
return false;
}
this.locked.add(key);
return true;
}
public async waitForLock(key: T): Promise<void> {
if (this.tryLock(key)) {
return Promise.resolve();
}
this.logger.debug(`Waiting for lock on ${key}`);
return new Promise((resolve) => {
let waiting = this.waiters.get(key);
if (!waiting) {
waiting = [];
this.waiters.set(key, waiting);
}
waiting.push(resolve);
});
}
public unlock(key: T): void {
if (!this.locked.has(key)) {
throw new Error(`Document ${key} is not locked, cannot unlock`);
}
// Remove the first element to ensure FIFO unblocking order
const nextWaiting = this.waiters.get(key)?.shift();
if (nextWaiting) {
this.logger.debug(`Granted lock on ${key}`);
nextWaiting();
} else {
this.locked.delete(key);
}
}
public reset(): void {
this.locked.clear();
this.waiters.clear();
}
}