Skip to content

Commit dfa9eb0

Browse files
authored
fix: improve processing of highly filtered changes feed (#224)
to avoid connectivity issues and timeouts in frontend
1 parent 458b41b commit dfa9eb0

File tree

3 files changed

+85
-10
lines changed

3 files changed

+85
-10
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ In both cases the following environment variables should be defined:
2828
- `JWT_SECRET` a secret to create JWT tokens. They are used in the JWT auth which works similar to CouchDB's `POST /_session` endpoint. This should be changed to prevent others to create fake JWT tokens.
2929
- `JWT_PUBLIC_KEY` the public key which can be used to validate a JWT in the authorization header (bearer). The structure is the same as and compatible with [CouchDB JWT auth](https://docs.couchdb.org/en/stable/api/server/authn.html#jwt-authentication).
3030
- `SENTRY_DSN` (optional) the [Sentry DSN](https://docs.sentry.io/product/sentry-basics/dsn-explainer/). If defined, error messages are sent to the sentry.io application monitoring & logging service.
31+
- `KEYCLOAK_ADMIN_BASE_URL` (optional) the base URL of the Keycloak server (e.g. `https://keycloak.example.com`). Required to enable the `/api/v1/permissions/check` endpoint, which resolves user roles via the Keycloak Admin API. If not set, the endpoint returns a 502 error but all other functionality continues to work.
32+
- `KEYCLOAK_REALM` (optional, required together with `KEYCLOAK_ADMIN_BASE_URL`) the Keycloak realm name.
33+
- `KEYCLOAK_ADMIN_CLIENT_ID` (optional, required together with `KEYCLOAK_ADMIN_BASE_URL`) the Keycloak client ID used to authenticate against the Keycloak Admin API.
34+
- `KEYCLOAK_ADMIN_CLIENT_SECRET` (optional, required together with `KEYCLOAK_ADMIN_BASE_URL`) the client secret for `KEYCLOAK_ADMIN_CLIENT_ID`.
3135
When `KEYCLOAK_ADMIN_BASE_URL` uses HTTPS with a self-signed CA (e.g. the local Caddy proxy), set `NODE_EXTRA_CA_CERTS` to the CA cert path before starting Node (see the local dev section below).
3236

3337
In case the backend is run through Docker, the args can be provided like this

src/restricted-endpoints/replication/changes/changes.controller.spec.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ConfigService } from '@nestjs/config';
12
import { Test, TestingModule } from '@nestjs/testing';
23
import { Observable, of } from 'rxjs';
34
import { authGuardMockProviders } from '../../../auth/auth-guard-mock.providers';
@@ -10,9 +11,8 @@ import {
1011
ChangeResult,
1112
ChangesResponse,
1213
} from '../bulk-document/couchdb-dtos/changes.dto';
13-
import { ChangesController } from './changes.controller';
1414
import { DocumentFilterService } from '../document-filter/document-filter.service';
15-
import { ConfigService } from '@nestjs/config';
15+
import { ChangesController } from './changes.controller';
1616

1717
describe('ChangesController', () => {
1818
let controller: ChangesController;
@@ -76,6 +76,7 @@ describe('ChangesController', () => {
7676

7777
expect(mockCouchdbService.get).toHaveBeenCalledWith('some-db', '_changes', {
7878
...params,
79+
limit: 1000,
7980
include_docs: true,
8081
});
8182
});
@@ -339,10 +340,7 @@ describe('ChangesController', () => {
339340

340341
const res = await controller.changes('some-db', user);
341342

342-
expect(res.results.map((r) => r.id)).toEqual([
343-
schoolDoc._id,
344-
childDoc._id,
345-
]);
343+
expect(res.results.map((r) => r.id)).toEqual([schoolDoc._id, childDoc._id]);
346344
expect(res.lostPermissions).toEqual([]);
347345
});
348346

src/restricted-endpoints/replication/changes/changes.controller.ts

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
1+
import {
2+
Controller,
3+
Get,
4+
Logger,
5+
Param,
6+
Query,
7+
UseGuards,
8+
} from '@nestjs/common';
29
import { omit } from 'lodash';
310
import { firstValueFrom, map } from 'rxjs';
411
import { CombinedAuthGuard } from '../../../auth/guards/combined-auth/combined-auth.guard';
@@ -16,9 +23,34 @@ import {
1623
} from '../bulk-document/couchdb-dtos/changes.dto';
1724
import { DocumentFilterService } from '../document-filter/document-filter.service';
1825

26+
/**
27+
* Multiplier applied to the client-requested limit when fetching from CouchDB.
28+
* Since permission filtering removes a fraction of results, fetching more per
29+
* CouchDB round-trip reduces the number of iterations needed to fill the
30+
* client's requested limit.
31+
*/
32+
const INTERNAL_LIMIT_MULTIPLIER = 5;
33+
34+
/**
35+
* Maximum time (ms) to spend iterating through CouchDB changes before
36+
* returning a partial response. Browsers (Chrome in particular) abort idle
37+
* HTTP connections after ~10 s with ERR_NETWORK_CHANGED, so we must respond
38+
* well within that window. The client (PouchDB) will follow up with another
39+
* `_changes` request using the returned `last_seq`.
40+
*/
41+
const MAX_PROCESSING_TIME_MS = 8000;
42+
43+
/**
44+
* Hard upper cap on the number of changes requested from CouchDB in a single
45+
* round-trip. Protects the backend from very large client-supplied limits.
46+
*/
47+
const MAX_INTERNAL_LIMIT = 1000;
48+
1949
@UseGuards(CombinedAuthGuard)
2050
@Controller()
2151
export class ChangesController {
52+
private readonly logger = new Logger(ChangesController.name);
53+
2254
constructor(
2355
private couchdbService: CouchdbService,
2456
private permissionService: PermissionService,
@@ -39,10 +71,14 @@ export class ChangesController {
3971
@User() user: UserInfo,
4072
@Query() params?: ChangesParams,
4173
): Promise<ChangesResponse> {
74+
const startTime = Date.now();
4275
const ability = this.permissionService.getAbilityFor(user);
4376
const change = { results: [], lostPermissions: [] } as ChangesResponse;
4477
let since = params?.since;
78+
let iterations = 0;
79+
let totalFetched = 0;
4580
while (true) {
81+
iterations++;
4682
const remainingChangesUntilLimit =
4783
(params?.limit ?? Infinity) - change.results.length;
4884
const res = await this.getPermittedChanges(
@@ -51,16 +87,19 @@ export class ChangesController {
5187
ability,
5288
remainingChangesUntilLimit,
5389
);
90+
totalFetched += (res as any)._totalFetchedFromCouch ?? 0;
5491
change.results.push(...res.results);
5592
change.lostPermissions.push(...(res.lostPermissions ?? []));
5693
change.last_seq = res.last_seq;
5794
change.pending = res.pending;
95+
const elapsed = Date.now() - startTime;
5896
if (
5997
!params?.limit ||
6098
change.pending === 0 ||
61-
change.results.length >= params.limit
99+
change.results.length >= params.limit ||
100+
elapsed >= MAX_PROCESSING_TIME_MS
62101
) {
63-
// enough changes found or none left
102+
// enough changes found, none left, or time budget exhausted
64103
break;
65104
}
66105
since = res.last_seq;
@@ -69,6 +108,22 @@ export class ChangesController {
69108
// remove doc content if not requested
70109
change.results = change.results.map((c) => omit(c, 'doc'));
71110
}
111+
112+
const duration = Date.now() - startTime;
113+
if (duration > 2000 || iterations > 2) {
114+
this.logger.warn(
115+
`_changes for "${db}" user="${user.name}" took ${duration}ms ` +
116+
`(${iterations} iterations, ${totalFetched} fetched from CouchDB, ` +
117+
`${change.results.length} permitted, ${change.lostPermissions?.length ?? 0} lost, ` +
118+
`since=${params?.since ?? 'undefined'}, limit=${params?.limit ?? 'none'}, pending=${change.pending})`,
119+
);
120+
} else {
121+
this.logger.debug(
122+
`_changes for "${db}" user="${user.name}": ${duration}ms, ` +
123+
`${iterations} iterations, ${change.results.length} results`,
124+
);
125+
}
126+
72127
return change;
73128
}
74129

@@ -78,13 +133,31 @@ export class ChangesController {
78133
ability: DocumentAbility,
79134
limit: number = Infinity,
80135
): Promise<ChangesResponse> {
136+
// Fetch more from CouchDB than needed, since permission filtering will
137+
// discard a portion of results. Base the multiplier on the *remaining*
138+
// limit (which shrinks each iteration) rather than the original client
139+
// limit, and apply a hard cap to protect against very large requests.
140+
const internalLimit = Math.min(
141+
limit * INTERNAL_LIMIT_MULTIPLIER,
142+
MAX_INTERNAL_LIMIT,
143+
);
144+
81145
return firstValueFrom(
82146
this.couchdbService
83147
.get<ChangesResponse>(db, '_changes', {
84148
...params,
149+
limit: internalLimit,
85150
include_docs: true,
86151
})
87-
.pipe(map((res) => this.filterChanges(res, ability, limit))),
152+
.pipe(
153+
map((res) => {
154+
const totalFetched = res.results?.length ?? 0;
155+
const filtered = this.filterChanges(res, ability, limit);
156+
// Attach metadata for logging (not sent to client — stripped by JSON serialization)
157+
(filtered as any)._totalFetchedFromCouch = totalFetched;
158+
return filtered;
159+
}),
160+
),
88161
);
89162
}
90163

0 commit comments

Comments
 (0)