Skip to content

Commit

Permalink
fix(sync): manually manage continuous sync (#2312)
Browse files Browse the repository at this point in the history
for better error handling
also prevents aborted sync after expired access token

closes #2100, closes #935
  • Loading branch information
sleidig authored Mar 22, 2024
1 parent 0b6dd33 commit b2f7234
Show file tree
Hide file tree
Showing 4 changed files with 63 additions and 126 deletions.
54 changes: 20 additions & 34 deletions src/app/core/database/sync.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,47 +33,34 @@ describe("SyncService", () => {
});

it("should restart the sync if it fails at one point", fakeAsync(() => {
let errorCallback, pauseCallback;
const syncHandle = {
on: (action, callback) => {
if (action === "error") {
errorCallback = callback;
}
if (action === "paused") {
pauseCallback = callback;
}
return syncHandle;
},
cancel: () => undefined,
};
const syncSpy = jasmine
.createSpy()
.and.returnValues(Promise.resolve("first"), syncHandle, syncHandle);
const mockLocalDb = jasmine.createSpyObj(["sync"]);
spyOn(
TestBed.inject(Database) as PouchDatabase,
"getPouchDB",
).and.returnValue({ sync: syncSpy } as any);
).and.returnValue(mockLocalDb);

loginState.next(LoginState.LOGGED_IN);

service.startSync();

mockLocalDb.sync.and.resolveTo({});
tick(1000);
expect(mockLocalDb.sync).toHaveBeenCalled();

// error + logged in -> sync should restart
loginState.next(LoginState.LOGGED_IN);
syncSpy.calls.reset();
errorCallback();
expect(syncSpy).toHaveBeenCalled();
mockLocalDb.sync.calls.reset();
mockLocalDb.sync.and.rejectWith("sync request server error");
tick(SyncService.SYNC_INTERVAL);
expect(mockLocalDb.sync).toHaveBeenCalled();
// expect no errors thrown in service

// pause -> no restart required
syncSpy.calls.reset();
pauseCallback();
expect(syncSpy).not.toHaveBeenCalled();
// continue sync intervals
mockLocalDb.sync.calls.reset();
mockLocalDb.sync.and.resolveTo({});
tick(SyncService.SYNC_INTERVAL);
expect(mockLocalDb.sync).toHaveBeenCalled();

// logout + error -> no restart
syncSpy.calls.reset();
loginState.next(LoginState.LOGGED_OUT);
tick();
errorCallback();
expect(syncSpy).not.toHaveBeenCalled();
service.liveSyncEnabled = false;
tick(SyncService.SYNC_INTERVAL);
}));

it("should try auto-login if fetch fails and fetch again", async () => {
Expand Down Expand Up @@ -110,7 +97,6 @@ describe("SyncService", () => {
expect(mockAuthService.login).toHaveBeenCalled();
expect(mockAuthService.addAuthHeader).toHaveBeenCalledTimes(2);

// prevent live sync call
service["cancelLiveSync"]();
service.liveSyncEnabled = false;
});
});
104 changes: 24 additions & 80 deletions src/app/core/database/sync.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { AppSettings } from "../app-settings";
import { HttpStatusCode } from "@angular/common/http";
import PouchDB from "pouchdb-browser";
import { SyncState } from "../session/session-states/sync-state.enum";
import { LoginStateSubject, SyncStateSubject } from "../session/session-type";
import { LoginState } from "../session/session-states/login-state.enum";
import { filter } from "rxjs/operators";
import { SyncStateSubject } from "../session/session-type";
import { filter, mergeMap, repeat, retry, takeWhile } from "rxjs/operators";
import { KeycloakAuthService } from "../session/auth/keycloak/keycloak-auth.service";
import { Config } from "../config/config";
import { Entity } from "../entity/model/entity";
import { from, of } from "rxjs";

/**
* This service initializes the remote DB and manages the sync between the local and remote DB.
Expand All @@ -22,8 +22,8 @@ import { Entity } from "../entity/model/entity";
export class SyncService {
static readonly LAST_SYNC_KEY = "LAST_SYNC";
private readonly POUCHDB_SYNC_BATCH_SIZE = 500;
private _liveSyncHandle: any;
private _liveSyncScheduledHandle: any;
static readonly SYNC_INTERVAL = 60000;

private remoteDatabase = new PouchDatabase(this.loggingService);
private remoteDB: PouchDB.Database;
private localDB: PouchDB.Database;
Expand All @@ -33,7 +33,6 @@ export class SyncService {
private loggingService: LoggingService,
private authService: KeycloakAuthService,
private syncStateSubject: SyncStateSubject,
private loginStateSubject: LoginStateSubject,
) {
this.logSyncContext();

Expand Down Expand Up @@ -64,10 +63,7 @@ export class SyncService {
*/
startSync() {
this.initDatabases();
this.sync()
.catch((err) => this.loggingService.warn(`Initial sync failed: ${err}`))
// Call live sync even when initial sync fails
.finally(() => this.liveSyncDeferred());
this.liveSync();
}

/**
Expand Down Expand Up @@ -107,92 +103,40 @@ export class SyncService {
return PouchDB.fetch(url, opts);
}

private sync(): Promise<any> {
private sync(): Promise<SyncResult> {
this.syncStateSubject.next(SyncState.STARTED);

return this.localDB
.sync(this.remoteDB, {
batch_size: this.POUCHDB_SYNC_BATCH_SIZE,
})
.then(() => {
.then((res) => {
this.syncStateSubject.next(SyncState.COMPLETED);
return res as SyncResult;
})
.catch((err) => {
this.loggingService.debug("sync error", err);
this.syncStateSubject.next(SyncState.FAILED);
throw err;
});
}

/**
* Schedules liveSync to be started.
* This method should be used to start the liveSync after the initial non-live sync,
* so the browser makes a round trip to the UI and hides the potentially visible first-sync dialog.
* @param timeout ms to wait before starting the liveSync
*/
private liveSyncDeferred(timeout = 1000) {
this._liveSyncScheduledHandle = setTimeout(() => this.liveSync(), timeout);
}

/**
* Start live sync in background.
* Continuous syncing in background.
*/
liveSyncEnabled: boolean;
private liveSync() {
this.cancelLiveSync(); // cancel any liveSync that may have been alive before
this.syncStateSubject.next(SyncState.STARTED);
this._liveSyncHandle = this.localDB.sync(this.remoteDB, {
live: true,
retry: true,
});
this._liveSyncHandle
.on("paused", () => {
// replication was paused: either because sync is finished or because of a failed sync (mostly due to lost connection). info is empty.
if (this.isLoggedIn()) {
this.syncStateSubject.next(SyncState.COMPLETED);
// We might end up here after a failed sync that is not due to offline errors.
// It shouldn't happen too often, as we have an initial non-live sync to catch those situations, but we can't find that out here
}
})
.on("active", () => {
// replication was resumed: either because new things to sync or because connection is available again. info contains the direction
this.syncStateSubject.next(SyncState.STARTED);
})
.on("error", this.handleFailedSync())
.on("complete", (info) => {
this.loggingService.info(
`Live sync completed: ${JSON.stringify(info)}`,
);
this.syncStateSubject.next(SyncState.COMPLETED);
});
}

private handleFailedSync() {
return (info) => {
if (this.isLoggedIn()) {
this.syncStateSubject.next(SyncState.FAILED);
const lastAuth = localStorage.getItem(
KeycloakAuthService.LAST_AUTH_KEY,
);
this.loggingService.warn(
`Live sync failed (last auth ${lastAuth}): ${JSON.stringify(info)}`,
);
this.liveSync();
}
};
}

private isLoggedIn(): boolean {
return this.loginStateSubject.value === LoginState.LOGGED_IN;
}
this.liveSyncEnabled = true;

/**
* Cancels a currently running liveSync or a liveSync scheduled to start in the future.
*/
private cancelLiveSync() {
if (this._liveSyncScheduledHandle) {
clearTimeout(this._liveSyncScheduledHandle);
}
if (this._liveSyncHandle) {
this._liveSyncHandle.cancel();
}
this.syncStateSubject.next(SyncState.UNSYNCED);
of(true)
.pipe(
mergeMap(() => from(this.sync())),
retry({ delay: SyncService.SYNC_INTERVAL }),
repeat({ delay: SyncService.SYNC_INTERVAL }),
takeWhile(() => this.liveSyncEnabled),
)
.subscribe();
}
}

type SyncResult = PouchDB.Replication.SyncResultComplete<any>;
5 changes: 3 additions & 2 deletions src/app/core/logging/logging.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ describe("LoggingService", () => {
expect(loggingService).toBeTruthy();
});

it("should log a debug message", function () {
loggingService.debug(testMessage);
it("should log a debug message with additional context", function () {
loggingService.debug(testMessage, "extra context");

expect(loggingService["logToConsole"]).toHaveBeenCalledWith(
testMessage,
LogLevel.DEBUG,
"extra context",
);
expect(loggingService["logToRemoteMonitoring"]).not.toHaveBeenCalled();
});
Expand Down
26 changes: 16 additions & 10 deletions src/app/core/logging/logging.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@ export class LoggingService {
/**
* Log the message with "debug" level - for very detailed, non-essential information.
* @param message
* @param context Additional context for debugging
*/
public debug(message: any) {
this.log(message, LogLevel.DEBUG);
public debug(message: any, ...context: any[]) {
this.log(message, LogLevel.DEBUG, ...context);
}

/**
Expand Down Expand Up @@ -98,31 +99,36 @@ export class LoggingService {
* Generic logging of a message.
* @param message Message to be logged
* @param logLevel Optional log level - default is "info"
* @param context Additional context for debugging
*/
public log(message: any, logLevel: LogLevel = LogLevel.INFO) {
this.logToConsole(message, logLevel);
public log(
message: any,
logLevel: LogLevel = LogLevel.INFO,
...context: any[]
) {
this.logToConsole(message, logLevel, ...context);

if (logLevel !== LogLevel.DEBUG && logLevel !== LogLevel.INFO) {
this.logToRemoteMonitoring(message, logLevel);
}
}

private logToConsole(message: any, logLevel: LogLevel) {
private logToConsole(message: any, logLevel: LogLevel, ...context: any[]) {
switch (+logLevel) {
case LogLevel.DEBUG:
console.debug(message);
console.debug(message, ...context);
break;
case LogLevel.INFO:
console.info(message);
console.info(message, ...context);
break;
case LogLevel.WARN:
console.warn(message);
console.warn(message, ...context);
break;
case LogLevel.ERROR:
console.error(message);
console.error(message, ...context);
break;
default:
console.log(message);
console.log(message, ...context);
break;
}
}
Expand Down

0 comments on commit b2f7234

Please sign in to comment.