@powersync/service-core 1.27.1 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/LICENSE +201 -67
- package/dist/api/RouteAPI.d.ts +6 -0
- package/dist/api/api-metrics.js +4 -0
- package/dist/api/api-metrics.js.map +1 -1
- package/dist/api/diagnostics.d.ts +5 -0
- package/dist/api/diagnostics.js +17 -2
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/metrics/connection-metrics.d.ts +28 -0
- package/dist/metrics/connection-metrics.js +55 -0
- package/dist/metrics/connection-metrics.js.map +1 -0
- package/dist/metrics/metrics-index.d.ts +1 -0
- package/dist/metrics/metrics-index.js +1 -0
- package/dist/metrics/metrics-index.js.map +1 -1
- package/dist/metrics/metrics-interfaces.d.ts +7 -1
- package/dist/metrics/metrics-interfaces.js.map +1 -1
- package/dist/modules/loader.js +8 -2
- package/dist/modules/loader.js.map +1 -1
- package/dist/replication/AbstractReplicationJob.d.ts +2 -0
- package/dist/replication/AbstractReplicationJob.js +17 -1
- package/dist/replication/AbstractReplicationJob.js.map +1 -1
- package/dist/replication/AbstractReplicator.d.ts +8 -18
- package/dist/replication/AbstractReplicator.js +63 -64
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/replication/ReplicationModule.js +0 -6
- package/dist/replication/ReplicationModule.js.map +1 -1
- package/dist/routes/configure-fastify.js +5 -1
- package/dist/routes/configure-fastify.js.map +1 -1
- package/dist/routes/endpoints/admin.js +24 -22
- package/dist/routes/endpoints/admin.js.map +1 -1
- package/dist/routes/endpoints/socket-route.js +19 -27
- package/dist/routes/endpoints/socket-route.js.map +1 -1
- package/dist/routes/endpoints/sync-rules.js +17 -40
- package/dist/routes/endpoints/sync-rules.js.map +1 -1
- package/dist/routes/endpoints/sync-stream.js +25 -23
- package/dist/routes/endpoints/sync-stream.js.map +1 -1
- package/dist/routes/hooks.d.ts +1 -1
- package/dist/routes/hooks.js +2 -1
- package/dist/routes/hooks.js.map +1 -1
- package/dist/routes/sync-connection.d.ts +7 -0
- package/dist/routes/sync-connection.js +34 -0
- package/dist/routes/sync-connection.js.map +1 -0
- package/dist/storage/BucketStorageFactory.d.ts +30 -22
- package/dist/storage/BucketStorageFactory.js +14 -14
- package/dist/storage/BucketStorageFactory.js.map +1 -1
- package/dist/storage/PersistedSyncConfigContent.js +2 -2
- package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
- package/dist/storage/SyncRulesBucketStorage.d.ts +23 -2
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/streams/BroadcastIterable.js +8 -7
- package/dist/streams/BroadcastIterable.js.map +1 -1
- package/dist/streams/LastValueSink.d.ts +2 -2
- package/dist/streams/LastValueSink.js +8 -13
- package/dist/streams/LastValueSink.js.map +1 -1
- package/dist/streams/merge.d.ts +15 -31
- package/dist/streams/merge.js +136 -152
- package/dist/streams/merge.js.map +1 -1
- package/dist/sync/BucketChecksumState.d.ts +3 -2
- package/dist/sync/BucketChecksumState.js +12 -5
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/sync/sync.js +48 -38
- package/dist/sync/sync.js.map +1 -1
- package/dist/sync/util.d.ts +1 -2
- package/dist/sync/util.js +2 -5
- package/dist/sync/util.js.map +1 -1
- package/dist/system/ServiceContext.d.ts +7 -0
- package/dist/system/ServiceContext.js +3 -3
- package/dist/system/ServiceContext.js.map +1 -1
- package/dist/system/SyncConfigEngine.d.ts +135 -0
- package/dist/system/SyncConfigEngine.js +186 -0
- package/dist/system/SyncConfigEngine.js.map +1 -0
- package/dist/system/system-index.d.ts +1 -0
- package/dist/system/system-index.js +1 -0
- package/dist/system/system-index.js.map +1 -1
- package/dist/util/config/types.d.ts +4 -0
- package/package.json +8 -9
- package/src/api/RouteAPI.ts +6 -0
- package/src/api/api-metrics.ts +5 -0
- package/src/api/diagnostics.ts +22 -2
- package/src/metrics/connection-metrics.ts +75 -0
- package/src/metrics/metrics-index.ts +1 -0
- package/src/metrics/metrics-interfaces.ts +8 -1
- package/src/modules/loader.ts +16 -2
- package/src/replication/AbstractReplicationJob.ts +17 -1
- package/src/replication/AbstractReplicator.ts +83 -81
- package/src/replication/ReplicationModule.ts +0 -5
- package/src/routes/configure-fastify.ts +7 -1
- package/src/routes/endpoints/admin.ts +27 -20
- package/src/routes/endpoints/socket-route.ts +26 -38
- package/src/routes/endpoints/sync-rules.ts +22 -46
- package/src/routes/endpoints/sync-stream.ts +31 -27
- package/src/routes/hooks.ts +5 -1
- package/src/routes/sync-connection.ts +39 -0
- package/src/storage/BucketStorageFactory.ts +45 -29
- package/src/storage/PersistedSyncConfigContent.ts +2 -2
- package/src/storage/SyncRulesBucketStorage.ts +23 -2
- package/src/streams/BroadcastIterable.ts +8 -7
- package/src/streams/LastValueSink.ts +10 -16
- package/src/streams/merge.ts +149 -169
- package/src/sync/BucketChecksumState.ts +17 -5
- package/src/sync/sync.ts +71 -57
- package/src/sync/util.ts +2 -7
- package/src/system/ServiceContext.ts +8 -3
- package/src/system/SyncConfigEngine.ts +247 -0
- package/src/system/system-index.ts +1 -0
- package/src/util/config/types.ts +4 -0
- package/test/src/AbstractReplicator.test.ts +375 -45
- package/test/src/PersistedSyncConfigContent.test.ts +5 -5
- package/test/src/SyncConfigEngine.test.ts +208 -0
- package/test/src/broadcast_iterable.test.ts +59 -31
- package/test/src/config.test.ts +65 -3
- package/test/src/connection-metrics-export.test.ts +76 -0
- package/test/src/connection-metrics.test.ts +184 -0
- package/test/src/diagnostics.test.ts +3 -3
- package/test/src/last_value_sink.test.ts +34 -0
- package/test/src/merge_iterable.test.ts +304 -55
- package/test/src/module-loader.test.ts +14 -1
- package/test/src/recording-metrics.ts +36 -0
- package/test/src/routes/admin.test.ts +2 -2
- package/test/src/routes/concurrency-limit.test.ts +108 -0
- package/test/src/routes/connection-metrics.test.ts +269 -0
- package/test/src/routes/invalid-config.test.ts +184 -0
- package/test/src/routes/mocks.ts +26 -13
- package/test/src/routes/stream-disconnect.integration.test.ts +129 -0
- package/test/src/routes/stream.test.ts +200 -107
- package/test/src/routes/sync-rules.test.ts +19 -1
- package/test/src/source_table_config.test.ts +7 -7
- package/test/src/sync/BucketChecksumState.test.ts +10 -10
- package/test/src/sync/stream_response.test.ts +389 -0
- package/test/src/sync/util.test.ts +14 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -99,7 +99,20 @@ describe('module loader', () => {
|
|
|
99
99
|
storage: { type: 'postgresql' }
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
-
await expect(loadModules(config as any, mockLoaders)).rejects.toThrowError(
|
|
102
|
+
await expect(loadModules(config as any, mockLoaders)).rejects.toThrowError(
|
|
103
|
+
'Invalid connection types: "redis". Supported types: "mysql", "postgresql".'
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should throw an error with supported storage types if storage type is invalid', async () => {
|
|
108
|
+
const config: MockConfig = {
|
|
109
|
+
connections: [{ type: 'mysql' }],
|
|
110
|
+
storage: { type: 'redis' }
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
await expect(loadModules(config as any, mockLoaders)).rejects.toThrowError(
|
|
114
|
+
'Invalid storage type: "redis". Supported types: "postgresql".'
|
|
115
|
+
);
|
|
103
116
|
});
|
|
104
117
|
|
|
105
118
|
it('should throw an error if one dynamic connection module import fails', async () => {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AggregationTemporality,
|
|
3
|
+
InMemoryMetricExporter,
|
|
4
|
+
MeterProvider,
|
|
5
|
+
PeriodicExportingMetricReader
|
|
6
|
+
} from '@opentelemetry/sdk-metrics';
|
|
7
|
+
|
|
8
|
+
import { createCoreAPIMetrics, initializeCoreAPIMetrics, MetricsEngine, OpenTelemetryMetricsFactory } from '@/index.js';
|
|
9
|
+
|
|
10
|
+
/** Collects real metrics and sums series matching the supplied labels. */
|
|
11
|
+
export function recordingMetricsEngine(name = 'route-test') {
|
|
12
|
+
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
|
13
|
+
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
|
|
14
|
+
const provider = new MeterProvider({ readers: [reader] });
|
|
15
|
+
const engine = new MetricsEngine({
|
|
16
|
+
factory: new OpenTelemetryMetricsFactory(provider.getMeter(name)),
|
|
17
|
+
disable_telemetry_sharing: true
|
|
18
|
+
});
|
|
19
|
+
createCoreAPIMetrics(engine);
|
|
20
|
+
initializeCoreAPIMetrics(engine);
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
engine,
|
|
24
|
+
shutdown: () => provider.shutdown(),
|
|
25
|
+
async seriesValue(metricName: string, attributes: Record<string, string>): Promise<number | undefined> {
|
|
26
|
+
await reader.forceFlush();
|
|
27
|
+
const metrics = exporter.getMetrics();
|
|
28
|
+
const scoped = metrics[metrics.length - 1]?.scopeMetrics?.[0]?.metrics;
|
|
29
|
+
const metric = scoped?.find((m) => m.descriptor.name === metricName);
|
|
30
|
+
const points = metric?.dataPoints.filter((p) =>
|
|
31
|
+
Object.entries(attributes).every(([k, v]) => p.attributes[k] === v)
|
|
32
|
+
);
|
|
33
|
+
return points?.length ? points.reduce((sum, point) => sum + (point.value as number), 0) : undefined;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BasicRouterRequest, Context, JwtPayload, ParsedSyncConfigSet, storage } from '@/index.js';
|
|
2
2
|
import { ErrorCode, logger } from '@powersync/lib-services-framework';
|
|
3
|
-
import { PrecompiledSyncConfig,
|
|
3
|
+
import { PrecompiledSyncConfig, parseSyncConfig } from '@powersync/service-sync-rules';
|
|
4
4
|
import { describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { diagnostics, reprocess, validate } from '../../../src/routes/endpoints/admin.js';
|
|
6
6
|
import { deploySyncRules, reprocessSyncRules, validateSyncRules } from '../../../src/routes/endpoints/sync-rules.js';
|
|
@@ -70,7 +70,7 @@ bucket_definitions:
|
|
|
70
70
|
syncConfigState: syncConfigStatus.state,
|
|
71
71
|
version_label: options.version_label,
|
|
72
72
|
parsed(options?: any) {
|
|
73
|
-
const syncRules =
|
|
73
|
+
const syncRules = parseSyncConfig(content.sync_rules_content, {
|
|
74
74
|
...options,
|
|
75
75
|
defaultSchema: 'public'
|
|
76
76
|
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { JwtPayload, routeDefinition } from '@/index.js';
|
|
2
|
+
import { router } from '@powersync/lib-services-framework';
|
|
3
|
+
import { APIMetric } from '@powersync/service-types';
|
|
4
|
+
import Fastify from 'fastify';
|
|
5
|
+
import { describe, expect, it, onTestFinished, vi } from 'vitest';
|
|
6
|
+
import { configureFastifyServer } from '../../../src/routes/configure-fastify.js';
|
|
7
|
+
import { syncStreamed } from '../../../src/routes/endpoints/sync-stream.js';
|
|
8
|
+
import { recordingMetricsEngine } from '../recording-metrics.js';
|
|
9
|
+
import { mockServiceContext } from './mocks.js';
|
|
10
|
+
|
|
11
|
+
describe('HTTP concurrency limit metrics', () => {
|
|
12
|
+
it('counts each rejected sync request without changing the HTTP 429 response', async () => {
|
|
13
|
+
const recorder = recordingMetricsEngine();
|
|
14
|
+
onTestFinished(() => recorder.shutdown());
|
|
15
|
+
const service = mockServiceContext(null, recorder.engine);
|
|
16
|
+
const entered = Promise.withResolvers<void>();
|
|
17
|
+
const release = Promise.withResolvers<void>();
|
|
18
|
+
vi.spyOn(service.storageEngine.activeBucketStorage, 'getActiveSyncConfig').mockImplementation(async () => {
|
|
19
|
+
entered.resolve();
|
|
20
|
+
await release.promise;
|
|
21
|
+
return null;
|
|
22
|
+
});
|
|
23
|
+
const app = Fastify();
|
|
24
|
+
configureFastifyServer(app, {
|
|
25
|
+
service_context: service,
|
|
26
|
+
routes: {
|
|
27
|
+
api: { routes: [] },
|
|
28
|
+
checkpointing: { routes: [] },
|
|
29
|
+
sync_stream: {
|
|
30
|
+
queue_options: { concurrency: 1, max_queue_depth: 0 },
|
|
31
|
+
routes: [
|
|
32
|
+
{
|
|
33
|
+
...syncStreamed,
|
|
34
|
+
authorize: async ({ context }) => {
|
|
35
|
+
context.token_payload = new JwtPayload({ sub: 'test-user', exp: Date.now() / 1000 + 10_000 });
|
|
36
|
+
return { authorized: true };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
const first = app.inject({ method: 'POST', url: '/sync/stream', payload: {} });
|
|
44
|
+
try {
|
|
45
|
+
await entered.promise;
|
|
46
|
+
for (let i = 0; i < 3; i++) {
|
|
47
|
+
const response = await app.inject({ method: 'POST', url: '/sync/stream', payload: {} });
|
|
48
|
+
expect(response.statusCode).toBe(429);
|
|
49
|
+
expect(response.body).toBe('');
|
|
50
|
+
}
|
|
51
|
+
expect(
|
|
52
|
+
await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {
|
|
53
|
+
outcome: 'rejected',
|
|
54
|
+
close_reason: 'concurrency_limit',
|
|
55
|
+
error_code: 'other',
|
|
56
|
+
transport: 'http_stream'
|
|
57
|
+
})
|
|
58
|
+
).toBe(3);
|
|
59
|
+
expect(await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {})).toBe(3);
|
|
60
|
+
expect(await recorder.seriesValue(APIMetric.CONCURRENT_CONNECTIONS, {})).toBe(0);
|
|
61
|
+
} finally {
|
|
62
|
+
release.resolve();
|
|
63
|
+
await first;
|
|
64
|
+
await app.close();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it.each(['api', 'checkpointing'] as const)('does not count %s queue rejections as sync attempts', async (group) => {
|
|
69
|
+
const recorder = recordingMetricsEngine();
|
|
70
|
+
onTestFinished(() => recorder.shutdown());
|
|
71
|
+
const entered = Promise.withResolvers<void>();
|
|
72
|
+
const release = Promise.withResolvers<void>();
|
|
73
|
+
const app = Fastify();
|
|
74
|
+
configureFastifyServer(app, {
|
|
75
|
+
service_context: mockServiceContext(null, recorder.engine),
|
|
76
|
+
routes: {
|
|
77
|
+
api: { routes: [] },
|
|
78
|
+
checkpointing: { routes: [] },
|
|
79
|
+
sync_stream: { routes: [] },
|
|
80
|
+
[group]: {
|
|
81
|
+
queue_options: { concurrency: 1, max_queue_depth: 0 },
|
|
82
|
+
routes: [
|
|
83
|
+
routeDefinition({
|
|
84
|
+
path: '/test',
|
|
85
|
+
method: router.HTTPMethod.GET,
|
|
86
|
+
handler: async () => {
|
|
87
|
+
entered.resolve();
|
|
88
|
+
await release.promise;
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
const first = app.inject({ method: 'GET', url: '/test' });
|
|
97
|
+
try {
|
|
98
|
+
await entered.promise;
|
|
99
|
+
const response = await app.inject({ method: 'GET', url: '/test' });
|
|
100
|
+
expect(response.statusCode).toBe(429);
|
|
101
|
+
expect((await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {})) ?? 0).toBe(0);
|
|
102
|
+
} finally {
|
|
103
|
+
release.resolve();
|
|
104
|
+
await first;
|
|
105
|
+
await app.close();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { Context, JwtPayload, RouterEngine, SyncRulesBucketStorage, SyncTransport } from '@/index.js';
|
|
2
|
+
import { ErrorCode, logger, RouterResponse, ServiceError } from '@powersync/lib-services-framework';
|
|
3
|
+
import {
|
|
4
|
+
handleReactiveStream,
|
|
5
|
+
ReactiveSocketRouter,
|
|
6
|
+
ReactiveStreamRequest,
|
|
7
|
+
SocketRouterObserver
|
|
8
|
+
} from '@powersync/service-rsocket-router';
|
|
9
|
+
import { DEFAULT_HYDRATION_STATE, nodeSqlite, parseSyncConfig } from '@powersync/service-sync-rules';
|
|
10
|
+
import { APIMetric } from '@powersync/service-types';
|
|
11
|
+
import * as sqlite from 'node:sqlite';
|
|
12
|
+
import { Readable } from 'node:stream';
|
|
13
|
+
import { describe, expect, it, onTestFinished, vi } from 'vitest';
|
|
14
|
+
import { syncStreamReactive } from '../../../src/routes/endpoints/socket-route.js';
|
|
15
|
+
import { syncStreamed } from '../../../src/routes/endpoints/sync-stream.js';
|
|
16
|
+
import { recordingMetricsEngine } from '../recording-metrics.js';
|
|
17
|
+
import { mockServiceContext } from './mocks.js';
|
|
18
|
+
|
|
19
|
+
function testContext(storage: Partial<SyncRulesBucketStorage> | null) {
|
|
20
|
+
const recorder = recordingMetricsEngine();
|
|
21
|
+
onTestFinished(() => recorder.shutdown());
|
|
22
|
+
const service = mockServiceContext(storage, recorder.engine);
|
|
23
|
+
const api = service.routerEngine.getAPI();
|
|
24
|
+
service.routerEngine = new RouterEngine();
|
|
25
|
+
service.routerEngine.registerAPI({ ...api, shutdown: async () => {} });
|
|
26
|
+
const context: Context = {
|
|
27
|
+
service_context: service,
|
|
28
|
+
logger: logger.child({}),
|
|
29
|
+
token_payload: new JwtPayload({ sub: 'test-user', exp: Date.now() / 1000 + 10_000 })
|
|
30
|
+
};
|
|
31
|
+
return { recorder, context, service };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function startConnection(transport: SyncTransport, context: Context) {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const disconnected = Promise.withResolvers<void>();
|
|
37
|
+
const errors: unknown[] = [];
|
|
38
|
+
let response: RouterResponse | undefined;
|
|
39
|
+
let clientClosed = false;
|
|
40
|
+
const disconnect = () => {
|
|
41
|
+
clientClosed = true;
|
|
42
|
+
controller.abort();
|
|
43
|
+
(response?.data as Readable | undefined)?.destroy();
|
|
44
|
+
disconnected.resolve();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const finished =
|
|
48
|
+
transport === SyncTransport.HttpStream
|
|
49
|
+
? (async () => {
|
|
50
|
+
try {
|
|
51
|
+
response = (await syncStreamed.handler({
|
|
52
|
+
context,
|
|
53
|
+
params: {},
|
|
54
|
+
request: { headers: {}, hostname: '', protocol: 'http' }
|
|
55
|
+
})) as RouterResponse;
|
|
56
|
+
const drained = (async () => {
|
|
57
|
+
try {
|
|
58
|
+
for await (const _chunk of response!.data as Readable) {
|
|
59
|
+
// Consume the response as the HTTP router would.
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
errors.push(error);
|
|
63
|
+
disconnect();
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
// Fastify runs afterSend on socket close, without waiting for the iterator to finish.
|
|
67
|
+
await Promise.race([drained, disconnected.promise]);
|
|
68
|
+
await response.afterSend({ clientClosed });
|
|
69
|
+
await drained;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
errors.push(error);
|
|
72
|
+
}
|
|
73
|
+
})()
|
|
74
|
+
: handleReactiveStream(
|
|
75
|
+
context,
|
|
76
|
+
{
|
|
77
|
+
payload: { data: Buffer.from('{}'), metadata: Buffer.from(JSON.stringify({ path: '/sync/stream' })) },
|
|
78
|
+
metadataMimeType: 'application/json',
|
|
79
|
+
dataMimeType: 'application/json',
|
|
80
|
+
initialN: 10,
|
|
81
|
+
responder: {
|
|
82
|
+
onNext() {},
|
|
83
|
+
onComplete() {},
|
|
84
|
+
onExtension() {},
|
|
85
|
+
onError(error) {
|
|
86
|
+
errors.push(error);
|
|
87
|
+
disconnect();
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
connection: { tracker: {} } as ReactiveStreamRequest['connection']
|
|
91
|
+
},
|
|
92
|
+
new SocketRouterObserver(),
|
|
93
|
+
controller,
|
|
94
|
+
{
|
|
95
|
+
contextProvider: async () => context,
|
|
96
|
+
endpoints: [syncStreamReactive(new ReactiveSocketRouter())],
|
|
97
|
+
metaDecoder: async (buffer) => JSON.parse(buffer.contents.toString()),
|
|
98
|
+
payloadDecoder: async (buffer) => buffer && JSON.parse(buffer.contents.toString())
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
onTestFinished(async () => {
|
|
102
|
+
disconnect();
|
|
103
|
+
await finished;
|
|
104
|
+
});
|
|
105
|
+
return { finished, disconnect, errors };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function idleStorage() {
|
|
109
|
+
const ready = Promise.withResolvers<void>();
|
|
110
|
+
const ending = Promise.withResolvers<void>();
|
|
111
|
+
const storage: Partial<SyncRulesBucketStorage> = {
|
|
112
|
+
getParsedSyncRules: () =>
|
|
113
|
+
parseSyncConfig('config:\n edition: 3\nstreams: {}', {
|
|
114
|
+
defaultSchema: 'public'
|
|
115
|
+
}).config.hydrate({
|
|
116
|
+
hydrationState: DEFAULT_HYDRATION_STATE,
|
|
117
|
+
sqlite: nodeSqlite(sqlite)
|
|
118
|
+
}),
|
|
119
|
+
async *watchCheckpointChanges({ signal }) {
|
|
120
|
+
const onAbort = () => ending.resolve();
|
|
121
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
122
|
+
if (signal.aborted) onAbort();
|
|
123
|
+
ready.resolve();
|
|
124
|
+
try {
|
|
125
|
+
await ending.promise;
|
|
126
|
+
} finally {
|
|
127
|
+
signal.removeEventListener('abort', onAbort);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
return { storage, ready: ready.promise, finish: () => ending.resolve(), fail: ending.reject };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
describe.each([SyncTransport.HttpStream, SyncTransport.RSocket])('%s connection metrics', (transport) => {
|
|
135
|
+
it.each([
|
|
136
|
+
['service_unavailable', 'PSYNC_S2003'],
|
|
137
|
+
['no_sync_config', 'PSYNC_S2302'],
|
|
138
|
+
['storage_error', 'PSYNC_S2403'],
|
|
139
|
+
['storage_error', 'other']
|
|
140
|
+
])('counts %s / %s once without accepting a stream', async (closeReason, errorCode) => {
|
|
141
|
+
const { recorder, context, service } = testContext(null);
|
|
142
|
+
if (closeReason === 'service_unavailable') {
|
|
143
|
+
await service.routerEngine.shutDown();
|
|
144
|
+
} else if (closeReason === 'storage_error') {
|
|
145
|
+
const error =
|
|
146
|
+
errorCode === 'other'
|
|
147
|
+
? new Error('Storage lookup failed')
|
|
148
|
+
: new ServiceError(ErrorCode.PSYNC_S2403, 'Storage query timed out');
|
|
149
|
+
vi.spyOn(service.storageEngine.activeBucketStorage, 'getActiveSyncConfig').mockRejectedValue(error);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const connection = startConnection(transport, context);
|
|
153
|
+
await connection.finished;
|
|
154
|
+
expect(connection.errors).toHaveLength(1);
|
|
155
|
+
if (errorCode !== 'other') {
|
|
156
|
+
expect(connection.errors[0]).toMatchObject({ errorData: { code: errorCode } });
|
|
157
|
+
}
|
|
158
|
+
expect(
|
|
159
|
+
await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {
|
|
160
|
+
transport,
|
|
161
|
+
outcome: 'rejected',
|
|
162
|
+
close_reason: closeReason,
|
|
163
|
+
error_code: errorCode
|
|
164
|
+
})
|
|
165
|
+
).toBe(1);
|
|
166
|
+
expect(await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {})).toBe(1);
|
|
167
|
+
expect(await recorder.seriesValue(APIMetric.CONCURRENT_CONNECTIONS, {})).toBe(0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it.each(['client_closed', 'service_closed', 'process_shutdown'])(
|
|
171
|
+
'counts %s once and restores the active gauge',
|
|
172
|
+
async (closeReason) => {
|
|
173
|
+
const source = idleStorage();
|
|
174
|
+
const { recorder, context, service } = testContext(source.storage);
|
|
175
|
+
const connection = startConnection(transport, context);
|
|
176
|
+
await source.ready;
|
|
177
|
+
expect(await recorder.seriesValue(APIMetric.CONCURRENT_CONNECTIONS, {})).toBe(1);
|
|
178
|
+
expect(await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {})).toBeUndefined();
|
|
179
|
+
|
|
180
|
+
if (closeReason === 'client_closed') {
|
|
181
|
+
connection.disconnect();
|
|
182
|
+
} else if (closeReason === 'process_shutdown') {
|
|
183
|
+
await service.routerEngine.shutDown();
|
|
184
|
+
} else {
|
|
185
|
+
source.finish();
|
|
186
|
+
}
|
|
187
|
+
await connection.finished;
|
|
188
|
+
connection.disconnect();
|
|
189
|
+
await service.routerEngine.shutDown();
|
|
190
|
+
|
|
191
|
+
expect(
|
|
192
|
+
await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {
|
|
193
|
+
transport,
|
|
194
|
+
outcome: 'ok',
|
|
195
|
+
close_reason: closeReason
|
|
196
|
+
})
|
|
197
|
+
).toBe(1);
|
|
198
|
+
expect(await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {})).toBe(1);
|
|
199
|
+
expect(await recorder.seriesValue(APIMetric.CONCURRENT_CONNECTIONS, {})).toBe(0);
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
it('counts token expiry as an ok server close', async () => {
|
|
204
|
+
const source = idleStorage();
|
|
205
|
+
const { recorder, context } = testContext(source.storage);
|
|
206
|
+
context.token_payload = new JwtPayload({ sub: 'test-user', exp: 0 });
|
|
207
|
+
const connection = startConnection(transport, context);
|
|
208
|
+
await connection.finished;
|
|
209
|
+
|
|
210
|
+
expect(connection.errors).toEqual([]);
|
|
211
|
+
expect(
|
|
212
|
+
await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {
|
|
213
|
+
transport,
|
|
214
|
+
outcome: 'ok',
|
|
215
|
+
close_reason: 'service_closed'
|
|
216
|
+
})
|
|
217
|
+
).toBe(1);
|
|
218
|
+
expect(await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {})).toBe(1);
|
|
219
|
+
expect(await recorder.seriesValue(APIMetric.CONCURRENT_CONNECTIONS, {})).toBe(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it.each([
|
|
223
|
+
[new ServiceError(ErrorCode.PSYNC_S2305, 'Too many buckets'), 'PSYNC_S2305'],
|
|
224
|
+
[new Error('Storage read failed'), 'other']
|
|
225
|
+
] as const)('preserves an accepted stream failure through client disconnect: %s', async (error, errorCode) => {
|
|
226
|
+
const source = idleStorage();
|
|
227
|
+
const { recorder, context } = testContext(source.storage);
|
|
228
|
+
const connection = startConnection(transport, context);
|
|
229
|
+
await source.ready;
|
|
230
|
+
expect(await recorder.seriesValue(APIMetric.CONCURRENT_CONNECTIONS, {})).toBe(1);
|
|
231
|
+
source.fail(error);
|
|
232
|
+
await connection.finished;
|
|
233
|
+
|
|
234
|
+
expect(connection.errors).toHaveLength(1);
|
|
235
|
+
expect(
|
|
236
|
+
await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {
|
|
237
|
+
transport,
|
|
238
|
+
outcome: 'error',
|
|
239
|
+
close_reason: 'stream_error',
|
|
240
|
+
error_code: errorCode
|
|
241
|
+
})
|
|
242
|
+
).toBe(1);
|
|
243
|
+
expect(await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {})).toBe(1);
|
|
244
|
+
expect(await recorder.seriesValue(APIMetric.CONCURRENT_CONNECTIONS, {})).toBe(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('counts a sync config hydration failure as one rejection without accepting a stream', async () => {
|
|
248
|
+
const failure = new Error('Persisted sync config could not be hydrated');
|
|
249
|
+
const { recorder, context } = testContext({
|
|
250
|
+
getParsedSyncRules() {
|
|
251
|
+
throw failure;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
const connection = startConnection(transport, context);
|
|
255
|
+
await connection.finished;
|
|
256
|
+
|
|
257
|
+
expect(connection.errors).toEqual([failure]);
|
|
258
|
+
expect(
|
|
259
|
+
await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {
|
|
260
|
+
transport,
|
|
261
|
+
outcome: 'rejected',
|
|
262
|
+
close_reason: 'sync_config_error',
|
|
263
|
+
error_code: 'other'
|
|
264
|
+
})
|
|
265
|
+
).toBe(1);
|
|
266
|
+
expect(await recorder.seriesValue(APIMetric.SYNC_CONNECTIONS, {})).toBe(1);
|
|
267
|
+
expect(await recorder.seriesValue(APIMetric.CONCURRENT_CONNECTIONS, {})).toBe(0);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { BasicRouterRequest, Context, RouterEngine, storage, updateSyncRulesFromYaml } from '@/index.js';
|
|
2
|
+
import { diagnostics, reprocess } from '@/routes/endpoints/admin.js';
|
|
3
|
+
import { reprocessSyncRules } from '@/routes/endpoints/sync-rules.js';
|
|
4
|
+
import { SyncConfigEngine } from '@/system/SyncConfigEngine.js';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { mockServiceContext } from './mocks.js';
|
|
7
|
+
|
|
8
|
+
const VALID_CONFIG = /* yaml */ `
|
|
9
|
+
# Stored config.
|
|
10
|
+
config:
|
|
11
|
+
edition: 3
|
|
12
|
+
streams:
|
|
13
|
+
todos:
|
|
14
|
+
query: SELECT id FROM todos
|
|
15
|
+
`;
|
|
16
|
+
const INVALID_CONFIG = /* yaml */ `
|
|
17
|
+
# Local rejected config.
|
|
18
|
+
config:
|
|
19
|
+
edition: 3
|
|
20
|
+
streams:
|
|
21
|
+
broken:
|
|
22
|
+
query: SELECT FROM
|
|
23
|
+
`;
|
|
24
|
+
const REQUEST = { headers: {}, hostname: '', protocol: 'http' } as BasicRouterRequest;
|
|
25
|
+
|
|
26
|
+
function makeStoredConfig() {
|
|
27
|
+
return {
|
|
28
|
+
content: {
|
|
29
|
+
replicationStreamName: 'stored_slot',
|
|
30
|
+
version_label: 'stored',
|
|
31
|
+
sync_rules_content: VALID_CONFIG,
|
|
32
|
+
parsed: () => ({ syncConfigs: [updateSyncRulesFromYaml(VALID_CONFIG).config.parsed] }),
|
|
33
|
+
getSyncConfigStatus: async () => null
|
|
34
|
+
},
|
|
35
|
+
storage: { getStatus: vi.fn(async () => ({ snapshotDone: true })) }
|
|
36
|
+
} as unknown as storage.ResolvedSyncConfig;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function makeContext(content: string, active = true, connected = true) {
|
|
40
|
+
const service = mockServiceContext(null);
|
|
41
|
+
service.configuration = {
|
|
42
|
+
sync_rules: { present: true, content, version_label: 'local', exit_on_error: false }
|
|
43
|
+
} as typeof service.configuration;
|
|
44
|
+
const persisted = makeStoredConfig();
|
|
45
|
+
(service.storageEngine as any).activeBucketStorage = {
|
|
46
|
+
getActiveSyncConfig: async () => (active ? persisted : null),
|
|
47
|
+
getDeployingSyncConfig: async () => persisted
|
|
48
|
+
};
|
|
49
|
+
const api = service.routerEngine.getAPI();
|
|
50
|
+
service.routerEngine = new RouterEngine();
|
|
51
|
+
service.routerEngine.registerAPI(api);
|
|
52
|
+
service.syncConfigEngine = new SyncConfigEngine(service.configuration.sync_rules, () =>
|
|
53
|
+
service.routerEngine.getAPI()
|
|
54
|
+
);
|
|
55
|
+
vi.spyOn(api, 'getConnectionStatus').mockResolvedValue({ connected, errors: [], id: 'test_id', uri: 'test' });
|
|
56
|
+
return { context: { service_context: service } as Context, api, persisted };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('local invalid config diagnostics', () => {
|
|
60
|
+
it.each([{ connected: true }, { connected: false }])(
|
|
61
|
+
'overlays rejected config and preserves active diagnostics (connected=$connected)',
|
|
62
|
+
async ({ connected }) => {
|
|
63
|
+
const { context } = await makeContext(INVALID_CONFIG, true, connected);
|
|
64
|
+
const result = await diagnostics.handler({ context, request: REQUEST, params: { sync_rules_content: true } });
|
|
65
|
+
// Rejected configured YAML appears under deploying_sync_rules without hiding the active deployment.
|
|
66
|
+
expect(result.active_sync_rules?.content).toBe(VALID_CONFIG);
|
|
67
|
+
expect(result.active_sync_rules?.connections[0].slot_name).toBe('stored_slot');
|
|
68
|
+
expect(result.deploying_sync_rules?.content).toBe(INVALID_CONFIG);
|
|
69
|
+
expect(result.deploying_sync_rules?.version_label).toBe('local');
|
|
70
|
+
expect(result.deploying_sync_rules?.connections).toEqual([]);
|
|
71
|
+
expect(result.deploying_sync_rules?.errors.some((error) => error.level == 'fatal')).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
it('reports a rejected first config without content when not requested', async () => {
|
|
76
|
+
const { context } = await makeContext(INVALID_CONFIG, false);
|
|
77
|
+
const result = await diagnostics.handler({ context, request: REQUEST, params: {} });
|
|
78
|
+
expect(result.active_sync_rules).toBeUndefined();
|
|
79
|
+
expect(result.deploying_sync_rules?.content).toBeUndefined();
|
|
80
|
+
expect(result.deploying_sync_rules?.errors.some((error) => error.level == 'fatal')).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('reports local errors even when no source connection is configured', async () => {
|
|
84
|
+
const { context, api } = await makeContext(INVALID_CONFIG, false);
|
|
85
|
+
// Exercise the route's existing defensive fallback for a missing source adapter status.
|
|
86
|
+
vi.mocked(api.getConnectionStatus).mockResolvedValue(
|
|
87
|
+
undefined as unknown as Awaited<ReturnType<typeof api.getConnectionStatus>>
|
|
88
|
+
);
|
|
89
|
+
const result = await diagnostics.handler({ context, request: REQUEST, params: {} });
|
|
90
|
+
expect(result.connections).toEqual([]);
|
|
91
|
+
expect(result.deploying_sync_rules?.errors.some((error) => error.level == 'fatal')).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('keeps stored pending diagnostics when the local config is valid', async () => {
|
|
95
|
+
const { context } = await makeContext(VALID_CONFIG);
|
|
96
|
+
const result = await diagnostics.handler({ context, request: REQUEST, params: { sync_rules_content: true } });
|
|
97
|
+
expect(result.deploying_sync_rules?.content).toBe(VALID_CONFIG);
|
|
98
|
+
expect(result.deploying_sync_rules?.connections[0].slot_name).toBe('stored_slot');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it.each([{ connected: true }, { connected: false }])(
|
|
102
|
+
'reports rejected source capabilities on an API instance (connected=$connected)',
|
|
103
|
+
async ({ connected }) => {
|
|
104
|
+
const content = /* yaml */ `
|
|
105
|
+
# Rejected local MongoDB deployment.
|
|
106
|
+
config:
|
|
107
|
+
edition: 3
|
|
108
|
+
source_table_options:
|
|
109
|
+
orders:
|
|
110
|
+
mongodb_filter_expression: { $eq: ['$$doc.active', true] }
|
|
111
|
+
streams:
|
|
112
|
+
orders:
|
|
113
|
+
query: SELECT * FROM orders
|
|
114
|
+
`;
|
|
115
|
+
const { context, api } = await makeContext(content, true, connected);
|
|
116
|
+
// Only the API is running: no replicator has validated or persisted the local config.
|
|
117
|
+
const fatal = {
|
|
118
|
+
level: 'fatal' as const,
|
|
119
|
+
message: 'MongoDB pre-filtering is not supported',
|
|
120
|
+
location: { start_offset: 1, end_offset: 10 }
|
|
121
|
+
};
|
|
122
|
+
api.validateSourceCapabilities = vi.fn(async (config) => {
|
|
123
|
+
return config.content === content ? [fatal] : [];
|
|
124
|
+
});
|
|
125
|
+
const result = await diagnostics.handler({ context, request: REQUEST, params: { sync_rules_content: true } });
|
|
126
|
+
expect(result.active_sync_rules?.content).toBe(VALID_CONFIG);
|
|
127
|
+
expect(result.deploying_sync_rules).toMatchObject({
|
|
128
|
+
content,
|
|
129
|
+
version_label: 'local',
|
|
130
|
+
connections: [],
|
|
131
|
+
errors: expect.arrayContaining([expect.objectContaining(fatal)])
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
it('reports unexpected source capability failures without persisted status or requested content', async () => {
|
|
137
|
+
const { context, api } = await makeContext(VALID_CONFIG, false);
|
|
138
|
+
api.validateSourceCapabilities = vi.fn(async () => {
|
|
139
|
+
throw new Error('Capability validation failed');
|
|
140
|
+
});
|
|
141
|
+
const result = await diagnostics.handler({ context, request: REQUEST, params: {} });
|
|
142
|
+
expect(result.deploying_sync_rules?.content).toBeUndefined();
|
|
143
|
+
expect(result.deploying_sync_rules?.connections).toEqual([]);
|
|
144
|
+
expect(result.deploying_sync_rules?.errors).toEqual([
|
|
145
|
+
expect.objectContaining({ level: 'fatal', message: 'Capability validation failed' })
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('keeps stored pending diagnostics when local source capabilities only report warnings', async () => {
|
|
150
|
+
const { context, api } = await makeContext(VALID_CONFIG);
|
|
151
|
+
api.validateSourceCapabilities = vi.fn(async () => [{ level: 'warning' as const, message: 'Advisory warning' }]);
|
|
152
|
+
const result = await diagnostics.handler({ context, request: REQUEST, params: { sync_rules_content: true } });
|
|
153
|
+
expect(result.deploying_sync_rules?.connections[0].slot_name).toBe('stored_slot');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('manual reprocessing with rejected local config', () => {
|
|
158
|
+
it.each(
|
|
159
|
+
[reprocess, reprocessSyncRules].flatMap((route) =>
|
|
160
|
+
[false, true].map((capabilityFailure) => ({ route, capabilityFailure }))
|
|
161
|
+
)
|
|
162
|
+
)(
|
|
163
|
+
'blocks $route.path for local fatal errors (capability=$capabilityFailure)',
|
|
164
|
+
async ({ route, capabilityFailure }) => {
|
|
165
|
+
const { context, api } = await makeContext(capabilityFailure ? VALID_CONFIG : INVALID_CONFIG);
|
|
166
|
+
const backend = context.service_context.storageEngine.activeBucketStorage;
|
|
167
|
+
// Ensure no pending deployment exists, so invalid configured YAML is what blocks reprocessing.
|
|
168
|
+
vi.spyOn(backend, 'getDeployingSyncConfig').mockResolvedValue(null);
|
|
169
|
+
if (capabilityFailure) {
|
|
170
|
+
api.validateSourceCapabilities = async () => [{ level: 'fatal', message: 'Unsupported executor' }];
|
|
171
|
+
}
|
|
172
|
+
const update = vi.fn();
|
|
173
|
+
backend.updateSyncRules = update;
|
|
174
|
+
await expect(route.handler({ context, request: REQUEST, params: {} })).rejects.toMatchObject({
|
|
175
|
+
errorData: {
|
|
176
|
+
status: 409,
|
|
177
|
+
code: 'PSYNC_S4106',
|
|
178
|
+
description: 'Cannot reprocess while the configured sync config is invalid'
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
expect(update).not.toHaveBeenCalled();
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
});
|