Skip to content

Commit 68c4aae

Browse files
committed
fix(sync): better fault tolerance and logging during sync
1 parent 8cdbb88 commit 68c4aae

File tree

4 files changed

+79
-87
lines changed

4 files changed

+79
-87
lines changed

src/app/core/database/pouch-database.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
*/
1717

1818
import { PouchDatabase } from "./pouch-database";
19+
import { fakeAsync, tick } from "@angular/core/testing";
20+
import PouchDB from "pouchdb-browser";
21+
import { HttpStatusCode } from "@angular/common/http";
1922

2023
describe("PouchDatabase tests", () => {
2124
let database: PouchDatabase;
@@ -284,4 +287,36 @@ describe("PouchDatabase tests", () => {
284287

285288
await expectAsync(database.isEmpty()).toBeResolvedTo(false);
286289
});
290+
291+
it("should try auto-login if fetch fails and fetch again", fakeAsync(() => {
292+
const mockAuthService = jasmine.createSpyObj(["login", "addAuthHeader"]);
293+
database = new PouchDatabase(mockAuthService);
294+
database.initRemoteDB("");
295+
296+
mockAuthService.login.and.resolveTo();
297+
// providing "valid" token on second call
298+
let calls = 0;
299+
mockAuthService.addAuthHeader.and.callFake((headers) => {
300+
headers.Authorization = calls % 2 === 1 ? "valid" : "invalid";
301+
});
302+
spyOn(PouchDB, "fetch").and.callFake(async (url, opts) => {
303+
calls++;
304+
if (opts.headers["Authorization"] === "valid") {
305+
return new Response('{ "_id": "foo" }', { status: HttpStatusCode.Ok });
306+
} else {
307+
return {
308+
status: HttpStatusCode.Unauthorized,
309+
ok: false,
310+
} as Response;
311+
}
312+
});
313+
314+
database.get("Entity:ABC");
315+
tick();
316+
tick();
317+
318+
expect(PouchDB.fetch).toHaveBeenCalledTimes(2);
319+
expect(mockAuthService.login).toHaveBeenCalled();
320+
expect(mockAuthService.addAuthHeader).toHaveBeenCalledTimes(2);
321+
}));
287322
});

src/app/core/database/pouch-database.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ import { Logging } from "../logging/logging.service";
2020
import PouchDB from "pouchdb-browser";
2121
import memory from "pouchdb-adapter-memory";
2222
import { PerformanceAnalysisLogging } from "../../utils/performance-analysis-logging";
23-
import { Injectable } from "@angular/core";
23+
import { Injectable, Optional } from "@angular/core";
2424
import { firstValueFrom, Observable, Subject } from "rxjs";
2525
import { filter } from "rxjs/operators";
2626
import { HttpStatusCode } from "@angular/common/http";
2727
import { environment } from "../../../environments/environment";
28+
import { KeycloakAuthService } from "../session/auth/keycloak/keycloak-auth.service";
2829

2930
/**
3031
* Wrapper for a PouchDB instance to decouple the code from
@@ -66,7 +67,7 @@ export class PouchDatabase extends Database {
6667
/**
6768
* Create a PouchDB database manager.
6869
*/
69-
constructor() {
70+
constructor(@Optional() private authService?: KeycloakAuthService) {
7071
super();
7172
}
7273

@@ -105,27 +106,55 @@ export class PouchDatabase extends Database {
105106
*/
106107
initRemoteDB(
107108
dbName = `${environment.DB_PROXY_PREFIX}/${environment.DB_NAME}`,
108-
fetch = this.defaultFetch,
109109
): PouchDatabase {
110110
const options = {
111111
adapter: "http",
112112
skip_setup: true,
113-
fetch,
113+
fetch: (url: string | Request, opts: RequestInit) =>
114+
this.defaultFetch(url, opts),
114115
};
115116
this.pouchDB = new PouchDB(dbName, options);
116117
this.databaseInitialized.complete();
117118
return this;
118119
}
119120

120-
private defaultFetch(url, opts: any) {
121+
private defaultFetch: Fetch = async (url: string | Request, opts: any) => {
121122
if (typeof url !== "string") {
122-
return;
123+
const err = new Error("PouchDatabase.fetch: url is not a string");
124+
err["details"] = url;
125+
throw err;
123126
}
124127

125128
const remoteUrl =
126129
environment.DB_PROXY_PREFIX + url.split(environment.DB_PROXY_PREFIX)[1];
127-
return PouchDB.fetch(remoteUrl, opts);
128-
}
130+
this.authService.addAuthHeader(opts.headers);
131+
132+
let result: Response;
133+
try {
134+
result = await PouchDB.fetch(remoteUrl, opts);
135+
} catch (err) {
136+
Logging.warn("Failed to fetch from DB", err);
137+
}
138+
139+
// retry login if request failed with unauthorized
140+
if (!result || result.status === HttpStatusCode.Unauthorized) {
141+
try {
142+
await this.authService.login();
143+
this.authService.addAuthHeader(opts.headers);
144+
result = await PouchDB.fetch(remoteUrl, opts);
145+
} catch (err) {
146+
Logging.warn("Failed to fetch from DB", err);
147+
}
148+
}
149+
150+
if (!result || result.status >= 500) {
151+
throw new DatabaseException({
152+
error: "Failed to fetch from DB",
153+
actualResponse: result,
154+
});
155+
}
156+
return result;
157+
};
129158

130159
async getPouchDBOnceReady(): Promise<PouchDB.Database> {
131160
await firstValueFrom(this.databaseInitialized, {
@@ -438,7 +467,10 @@ export class PouchDatabase extends Database {
438467
* This overwrites PouchDB's error class which only logs limited information
439468
*/
440469
class DatabaseException extends Error {
441-
constructor(error: PouchDB.Core.Error, entityId?: string) {
470+
constructor(
471+
error: PouchDB.Core.Error | { [key: string]: any },
472+
entityId?: string,
473+
) {
442474
super();
443475

444476
if (entityId) {

src/app/core/database/sync.service.spec.ts

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import { Database } from "./database";
66
import { LoginStateSubject, SyncStateSubject } from "../session/session-type";
77
import { LoginState } from "../session/session-states/login-state.enum";
88
import { KeycloakAuthService } from "../session/auth/keycloak/keycloak-auth.service";
9-
import { HttpStatusCode } from "@angular/common/http";
10-
import PouchDB from "pouchdb-browser";
119
import { Subject } from "rxjs";
1210

1311
describe("SyncService", () => {
@@ -72,47 +70,6 @@ describe("SyncService", () => {
7270
stopPeriodicTimer();
7371
}));
7472

75-
it("should try auto-login if fetch fails and fetch again", fakeAsync(() => {
76-
// Make sync call pass
77-
spyOn(
78-
TestBed.inject(Database) as PouchDatabase,
79-
"getPouchDB",
80-
).and.returnValues({ sync: () => Promise.resolve() } as any);
81-
spyOn(PouchDB, "fetch").and.returnValues(
82-
Promise.resolve({
83-
status: HttpStatusCode.Unauthorized,
84-
ok: false,
85-
} as Response),
86-
Promise.resolve({ status: HttpStatusCode.Ok, ok: true } as Response),
87-
);
88-
// providing "valid" token on second call
89-
let calls = 0;
90-
mockAuthService.addAuthHeader.and.callFake((headers) => {
91-
headers.Authorization = calls++ === 1 ? "valid" : "invalid";
92-
});
93-
mockAuthService.login.and.resolveTo();
94-
const initSpy = spyOn(service["remoteDatabase"], "initRemoteDB");
95-
service.startSync();
96-
tick();
97-
// taking fetch function from init call
98-
const fetch = initSpy.calls.mostRecent().args[1];
99-
100-
const url = "/db/_changes";
101-
const opts = { headers: {} };
102-
let fetchResult;
103-
fetch(url, opts).then((res) => (fetchResult = res));
104-
tick();
105-
expect(fetchResult).toBeDefined();
106-
107-
expect(PouchDB.fetch).toHaveBeenCalledTimes(2);
108-
expect(PouchDB.fetch).toHaveBeenCalledWith(url, opts);
109-
expect(opts.headers).toEqual({ Authorization: "valid" });
110-
expect(mockAuthService.login).toHaveBeenCalled();
111-
expect(mockAuthService.addAuthHeader).toHaveBeenCalledTimes(2);
112-
113-
stopPeriodicTimer();
114-
}));
115-
11673
it("should sync immediately when local db has changes", fakeAsync(() => {
11774
const mockLocalDb = jasmine.createSpyObj(["sync"]);
11875
const db = TestBed.inject(Database) as PouchDatabase;

src/app/core/database/sync.service.ts

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import { Injectable } from "@angular/core";
22
import { Database } from "./database";
33
import { PouchDatabase } from "./pouch-database";
44
import { Logging } from "../logging/logging.service";
5-
import { HttpStatusCode } from "@angular/common/http";
6-
import PouchDB from "pouchdb-browser";
75
import { SyncState } from "../session/session-states/sync-state.enum";
86
import { SyncStateSubject } from "../session/session-type";
97
import {
@@ -30,7 +28,7 @@ export class SyncService {
3028
private readonly POUCHDB_SYNC_BATCH_SIZE = 500;
3129
static readonly SYNC_INTERVAL = 30000;
3230

33-
private remoteDatabase = new PouchDatabase();
31+
private remoteDatabase: PouchDatabase;
3432
private remoteDB: PouchDB.Database;
3533
private localDB: PouchDB.Database;
3634

@@ -39,6 +37,8 @@ export class SyncService {
3937
private authService: KeycloakAuthService,
4038
private syncStateSubject: SyncStateSubject,
4139
) {
40+
this.remoteDatabase = new PouchDatabase(this.authService);
41+
4242
this.logSyncContext();
4343

4444
this.syncStateSubject
@@ -78,45 +78,13 @@ export class SyncService {
7878
private initDatabases() {
7979
this.remoteDatabase.initRemoteDB(
8080
`${environment.DB_PROXY_PREFIX}/${environment.DB_NAME}`,
81-
this.fetch.bind(this),
8281
);
8382
this.remoteDB = this.remoteDatabase.getPouchDB();
8483
if (this.database instanceof PouchDatabase) {
8584
this.localDB = this.database.getPouchDB();
8685
}
8786
}
8887

89-
private async fetch(url: string, opts: any) {
90-
// TODO: merge this with PouchDatabase.defaultFetch, which is very similar
91-
92-
if (typeof url !== "string") {
93-
return;
94-
}
95-
96-
const remoteUrl =
97-
environment.DB_PROXY_PREFIX + url.split(environment.DB_PROXY_PREFIX)[1];
98-
const initialRes = await this.sendRequest(remoteUrl, opts);
99-
100-
// retry login if request failed with unauthorized
101-
if (initialRes.status === HttpStatusCode.Unauthorized) {
102-
return (
103-
this.authService
104-
.login()
105-
.then(() => this.sendRequest(remoteUrl, opts))
106-
// return initial response if request failed again
107-
.then((newRes) => (newRes.ok ? newRes : initialRes))
108-
.catch(() => initialRes)
109-
);
110-
} else {
111-
return initialRes;
112-
}
113-
}
114-
115-
private sendRequest(url: string, opts) {
116-
this.authService.addAuthHeader(opts.headers);
117-
return PouchDB.fetch(url, opts);
118-
}
119-
12088
/**
12189
* Execute a (one-time) sync between the local and server database.
12290
*/

0 commit comments

Comments
 (0)