From 21de621dc253ca4583cc3a7647077939b34e0dc5 Mon Sep 17 00:00:00 2001 From: Dominic Gunther Bauer <46312751+DominicGBauer@users.noreply.github.com> Date: Tue, 12 Nov 2024 09:14:29 +0200 Subject: [PATCH 1/3] feat: add http probes for health checks (#125) * feat: add http probes * chore: fix tests * chore: add changeset * chore: clean up --------- Co-authored-by: DominicGBauer --- .changeset/witty-maps-heal.md | 5 + .../src/routes/configure-fastify.ts | 3 +- .../src/routes/endpoints/probes.ts | 58 +++++ packages/service-core/src/routes/router.ts | 6 +- .../src/routes/probes.integration.test.ts | 231 ++++++++++++++++++ .../test/src/routes/probes.test.ts | 153 ++++++++++++ 6 files changed, 452 insertions(+), 4 deletions(-) create mode 100644 .changeset/witty-maps-heal.md create mode 100644 packages/service-core/src/routes/endpoints/probes.ts create mode 100644 packages/service-core/test/src/routes/probes.integration.test.ts create mode 100644 packages/service-core/test/src/routes/probes.test.ts diff --git a/.changeset/witty-maps-heal.md b/.changeset/witty-maps-heal.md new file mode 100644 index 000000000..3a60d3ec3 --- /dev/null +++ b/.changeset/witty-maps-heal.md @@ -0,0 +1,5 @@ +--- +'@powersync/service-core': patch +--- + +Add probe endpoints which can be used for system health checks. diff --git a/packages/service-core/src/routes/configure-fastify.ts b/packages/service-core/src/routes/configure-fastify.ts index b3f0209d1..3ff0f463d 100644 --- a/packages/service-core/src/routes/configure-fastify.ts +++ b/packages/service-core/src/routes/configure-fastify.ts @@ -5,6 +5,7 @@ import * as system from '../system/system-index.js'; import { ADMIN_ROUTES } from './endpoints/admin.js'; import { CHECKPOINT_ROUTES } from './endpoints/checkpointing.js'; +import { PROBES_ROUTES } from './endpoints/probes.js'; import { SYNC_RULES_ROUTES } from './endpoints/sync-rules.js'; import { SYNC_STREAM_ROUTES } from './endpoints/sync-stream.js'; import { createRequestQueueHook, CreateRequestQueueParams } from './hooks.js'; @@ -35,7 +36,7 @@ export type FastifyServerConfig = { export const DEFAULT_ROUTE_OPTIONS = { api: { - routes: [...ADMIN_ROUTES, ...CHECKPOINT_ROUTES, ...SYNC_RULES_ROUTES], + routes: [...ADMIN_ROUTES, ...CHECKPOINT_ROUTES, ...SYNC_RULES_ROUTES, ...PROBES_ROUTES], queueOptions: { concurrency: 10, max_queue_depth: 20 diff --git a/packages/service-core/src/routes/endpoints/probes.ts b/packages/service-core/src/routes/endpoints/probes.ts new file mode 100644 index 000000000..f38bbdaba --- /dev/null +++ b/packages/service-core/src/routes/endpoints/probes.ts @@ -0,0 +1,58 @@ +import { container, router } from "@powersync/lib-services-framework"; +import { routeDefinition } from "../router.js"; + +export enum ProbeRoutes { + STARTUP = '/probes/startup', + LIVENESS = '/probes/liveness', + READINESS = '/probes/readiness' +} + +export const startupCheck = routeDefinition({ + path: ProbeRoutes.STARTUP, + method: router.HTTPMethod.GET, + handler: async () => { + const state = container.probes.state(); + + return new router.RouterResponse({ + status: state.started ? 200 : 400, + data: { + ...state, + } + }); + } +}); + +export const livenessCheck = routeDefinition({ + path: ProbeRoutes.LIVENESS, + method: router.HTTPMethod.GET, + handler: async () => { + const state = container.probes.state(); + + const timeDifference = Date.now() - state.touched_at.getTime() + const status = timeDifference < 10000 ? 200 : 400; + + return new router.RouterResponse({ + status, + data: { + ...state, + } + }); + } +}); + +export const readinessCheck = routeDefinition({ + path: ProbeRoutes.READINESS, + method: router.HTTPMethod.GET, + handler: async () => { + const state = container.probes.state(); + + return new router.RouterResponse({ + status: state.ready ? 200 : 400, + data: { + ...state, + } + }); + } +}); + +export const PROBES_ROUTES = [startupCheck, livenessCheck, readinessCheck]; diff --git a/packages/service-core/src/routes/router.ts b/packages/service-core/src/routes/router.ts index 59a50f496..9cfdb3351 100644 --- a/packages/service-core/src/routes/router.ts +++ b/packages/service-core/src/routes/router.ts @@ -1,6 +1,6 @@ import { router } from '@powersync/lib-services-framework'; -import * as auth from '../auth/auth-index.js'; -import { CorePowerSyncSystem } from '../system/CorePowerSyncSystem.js'; +import type { JwtPayload } from '../auth/auth-index.js'; +import type { CorePowerSyncSystem } from '../system/CorePowerSyncSystem.js'; /** * Common context for routes @@ -9,7 +9,7 @@ export type Context = { user_id?: string; system: CorePowerSyncSystem; - token_payload?: auth.JwtPayload; + token_payload?: JwtPayload; token_errors?: string[]; /** * Only on websocket endpoints. diff --git a/packages/service-core/test/src/routes/probes.integration.test.ts b/packages/service-core/test/src/routes/probes.integration.test.ts new file mode 100644 index 000000000..5b1b3e276 --- /dev/null +++ b/packages/service-core/test/src/routes/probes.integration.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Fastify, { FastifyInstance } from 'fastify'; +import { container } from '@powersync/lib-services-framework'; +import * as auth from '../../../src/routes/auth.js'; +import * as system from '../../../src/system/system-index.js'; +import { configureFastifyServer } from '../../../src/index.js'; +import { ProbeRoutes } from '../../../src/routes/endpoints/probes.js'; + +vi.mock("@powersync/lib-services-framework", async () => { + const actual = await vi.importActual("@powersync/lib-services-framework") as any; + return { + ...actual, + container: { + ...actual.container, + probes: { + state: vi.fn() + } + }, + } +}) + +describe('Probe Routes Integration', () => { + let app: FastifyInstance; + let mockSystem: system.CorePowerSyncSystem; + + beforeEach(async () => { + app = Fastify(); + mockSystem = {} as system.CorePowerSyncSystem; + await configureFastifyServer(app, { system: mockSystem }); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('Startup Probe', () => { + it('returns 200 when system is started', async () => { + const mockState = { + started: true, + ready: true, + touched_at: new Date() + }; + vi.spyOn(auth, 'authUser').mockResolvedValue({ authorized: true }); + vi.mocked(container.probes.state).mockReturnValue(mockState); + + const response = await app.inject({ + method: 'GET', + url: ProbeRoutes.STARTUP, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload)).toEqual({ + ...mockState, + touched_at: mockState.touched_at.toISOString() + }); + }); + + it('returns 400 when system is not started', async () => { + const mockState = { + started: false, + ready: false, + touched_at: new Date() + }; + + vi.mocked(container.probes.state).mockReturnValue(mockState); + + const response = await app.inject({ + method: 'GET', + url: ProbeRoutes.STARTUP, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.payload)).toEqual({ + ...mockState, + touched_at: mockState.touched_at.toISOString() + }); + }); + }); + + describe('Liveness Probe', () => { + it('returns 200 when system was touched recently', async () => { + const mockState = { + started: true, + ready: true, + touched_at: new Date() + }; + + vi.mocked(container.probes.state).mockReturnValue(mockState); + + const response = await app.inject({ + method: 'GET', + url: ProbeRoutes.LIVENESS, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload)).toEqual({ + ...mockState, + touched_at: mockState.touched_at.toISOString() + }); + }); + + it('returns 400 when system has not been touched recently', async () => { + const mockState = { + started: true, + ready: true, + touched_at: new Date(Date.now() - 15000) // 15 seconds ago + }; + + vi.mocked(container.probes.state).mockReturnValue(mockState); + + const response = await app.inject({ + method: 'GET', + url: ProbeRoutes.LIVENESS, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.payload)).toEqual({ + ...mockState, + touched_at: mockState.touched_at.toISOString() + }); + }); + }); + + describe('Readiness Probe', () => { + it('returns 200 when system is ready', async () => { + const mockState = { + started: true, + ready: true, + touched_at: new Date() + }; + + vi.mocked(container.probes.state).mockReturnValue(mockState); + + const response = await app.inject({ + method: 'GET', + url: ProbeRoutes.READINESS, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload)).toEqual({ + ...mockState, + touched_at: mockState.touched_at.toISOString() + }); + }); + + it('returns 400 when system is not ready', async () => { + const mockState = { + started: true, + ready: false, + touched_at: new Date() + }; + + vi.mocked(container.probes.state).mockReturnValue(mockState); + + const response = await app.inject({ + method: 'GET', + url: ProbeRoutes.READINESS, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.payload)).toEqual({ + ...mockState, + touched_at: mockState.touched_at.toISOString() + }); + }); + }); + + describe('Request Queue Behavior', () => { + it('handles concurrent requests within limits', async () => { + const mockState = { started: true, ready: true, touched_at: new Date() }; + vi.mocked(container.probes.state).mockReturnValue(mockState); + + // Create array of 15 concurrent requests (default concurrency is 10) + const requests = Array(15).fill(null).map(() => + app.inject({ + method: 'GET', + url: ProbeRoutes.STARTUP, + }) + ); + + const responses = await Promise.all(requests); + + // All requests should complete successfully + responses.forEach(response => { + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload)).toEqual({ + ...mockState, + touched_at: mockState.touched_at.toISOString() + }); + }); + }); + + it('respects max queue depth', async () => { + const mockState = { started: true, ready: true, touched_at: new Date() }; + vi.mocked(container.probes.state).mockReturnValue(mockState); + + // Create array of 35 concurrent requests (default max_queue_depth is 20) + const requests = Array(35).fill(null).map(() => + app.inject({ + method: 'GET', + url: ProbeRoutes.STARTUP, + }) + ); + + const responses = await Promise.all(requests); + + // Some requests should succeed and some should fail with 429 + const successCount = responses.filter(r => r.statusCode === 200).length; + const queueFullCount = responses.filter(r => r.statusCode === 429).length; + + expect(successCount).toBeGreaterThan(0); + expect(queueFullCount).toBeGreaterThan(0); + expect(successCount + queueFullCount).toBe(35); + }); + }); + + describe('Content Types', () => { + it('returns correct content type headers', async () => { + const mockState = { started: true, ready: true, touched_at: new Date() }; + vi.mocked(container.probes.state).mockReturnValue(mockState); + + const response = await app.inject({ + method: 'GET', + url: ProbeRoutes.STARTUP, + }); + + expect(response.headers['content-type']).toMatch(/application\/json/); + }); + }); +}); diff --git a/packages/service-core/test/src/routes/probes.test.ts b/packages/service-core/test/src/routes/probes.test.ts new file mode 100644 index 000000000..102a60634 --- /dev/null +++ b/packages/service-core/test/src/routes/probes.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { container } from '@powersync/lib-services-framework'; +import { startupCheck, livenessCheck, readinessCheck } from '../../../src/routes/endpoints/probes.js'; + +// Mock the container +vi.mock('@powersync/lib-services-framework', () => ({ + container: { + probes: { + state: vi.fn() + } + }, + router: { + HTTPMethod: { + GET: 'GET' + }, + RouterResponse: class RouterResponse { + status: number; + data: any; + headers: Record; + afterSend: () => Promise; + __micro_router_response = true; + + constructor({ status, data, headers, afterSend }: { + status?: number; + data: any; + headers?: Record; + afterSend?: () => Promise; + }) { + this.status = status || 200; + this.data = data; + this.headers = headers || { 'Content-Type': 'application/json' }; + this.afterSend = afterSend ?? (() => Promise.resolve()); + } + } + } +})); + +describe('Probe Routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('startupCheck', () => { + it('has the correct route definitions', () => { + expect(startupCheck.path).toBe('/probes/startup'); + expect(startupCheck.method).toBe('GET'); + }); + + it('returns 200 when started is true', async () => { + const mockState = { + started: true, + ready: true, + touched_at: new Date() + }; + + vi.mocked(container.probes.state).mockReturnValue(mockState); + + const response = await startupCheck.handler(); + + expect(response.status).toEqual(200); + expect(response.data).toEqual(mockState); + }); + + it('returns 400 when started is false', async () => { + const mockState = { + started: false, + ready: false, + touched_at: new Date() + }; + + vi.mocked(container.probes.state).mockReturnValue(mockState); + + const response = await startupCheck.handler(); + + expect(response.status).toBe(400); + expect(response.data).toEqual(mockState); + }); + }); + + describe('livenessCheck', () => { + it('has the correct route definitions', () => { + expect(livenessCheck.path).toBe('/probes/liveness'); + expect(livenessCheck.method).toBe('GET'); + }); + + it('returns 200 when last touch was less than 10 seconds ago', async () => { + const mockState = { + started: true, + ready: true, + touched_at: new Date(Date.now() - 9000) // 11 seconds ago + }; + + vi.mocked(container.probes.state).mockReturnValue(mockState); + + const response = await livenessCheck.handler(); + + expect(response.status).toBe(200); + expect(response.data).toEqual(mockState); + }); + + it('returns 400 when last touch was more than 10 seconds ago', async () => { + const mockState = { + started: true, + ready: true, + touched_at: new Date(Date.now() - 11000) + }; + + vi.mocked(container.probes.state).mockReturnValue(mockState); + + const response = await livenessCheck.handler(); + + expect(response.status).toBe(400); + expect(response.data).toEqual(mockState); + }); + }); + + describe('readinessCheck', () => { + it('has the correct route definitions', () => { + expect(readinessCheck.path).toBe('/probes/readiness'); + expect(readinessCheck.method).toBe('GET'); + }); + + it('returns 200 when ready is true', async () => { + const mockState = { + started: true, + ready: true, + touched_at: new Date() + }; + + vi.mocked(container.probes.state).mockReturnValue(mockState); + + const response = await readinessCheck.handler(); + + expect(response.status).toBe(200); + expect(response.data).toEqual(mockState); + }); + + it('returns 400 when ready is false', async () => { + const mockState = { + started: true, + ready: false, + touched_at: new Date() + }; + + vi.mocked(container.probes.state).mockReturnValue(mockState); + + const response = await readinessCheck.handler(); + + expect(response.status).toBe(400); + expect(response.data).toEqual(mockState); + }); + }); +}); From 1c99a4d77e4322377eea0984aa81bbd3510ab1e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 12 Nov 2024 10:28:41 +0200 Subject: [PATCH 2/3] Version Packages (#127) Co-authored-by: github-actions[bot] --- .changeset/witty-maps-heal.md | 5 ----- packages/service-core/CHANGELOG.md | 6 ++++++ packages/service-core/package.json | 2 +- service/CHANGELOG.md | 7 +++++++ service/package.json | 2 +- test-client/CHANGELOG.md | 7 +++++++ test-client/package.json | 2 +- 7 files changed, 23 insertions(+), 8 deletions(-) delete mode 100644 .changeset/witty-maps-heal.md diff --git a/.changeset/witty-maps-heal.md b/.changeset/witty-maps-heal.md deleted file mode 100644 index 3a60d3ec3..000000000 --- a/.changeset/witty-maps-heal.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@powersync/service-core': patch ---- - -Add probe endpoints which can be used for system health checks. diff --git a/packages/service-core/CHANGELOG.md b/packages/service-core/CHANGELOG.md index 4a718373f..d314e8821 100644 --- a/packages/service-core/CHANGELOG.md +++ b/packages/service-core/CHANGELOG.md @@ -1,5 +1,11 @@ # @powersync/service-core +## 0.8.8 + +### Patch Changes + +- 21de621: Add probe endpoints which can be used for system health checks. + ## 0.8.7 ### Patch Changes diff --git a/packages/service-core/package.json b/packages/service-core/package.json index ad1fc1af3..2efb16ac5 100644 --- a/packages/service-core/package.json +++ b/packages/service-core/package.json @@ -5,7 +5,7 @@ "publishConfig": { "access": "public" }, - "version": "0.8.7", + "version": "0.8.8", "main": "dist/index.js", "license": "FSL-1.1-Apache-2.0", "type": "module", diff --git a/service/CHANGELOG.md b/service/CHANGELOG.md index fc767a8cf..d4f41a3b4 100644 --- a/service/CHANGELOG.md +++ b/service/CHANGELOG.md @@ -1,5 +1,12 @@ # @powersync/service-image +## 0.5.8 + +### Patch Changes + +- Updated dependencies [21de621] + - @powersync/service-core@0.8.8 + ## 0.5.7 ### Patch Changes diff --git a/service/package.json b/service/package.json index 1dfefaee0..c44fcad18 100644 --- a/service/package.json +++ b/service/package.json @@ -1,6 +1,6 @@ { "name": "@powersync/service-image", - "version": "0.5.7", + "version": "0.5.8", "private": true, "license": "FSL-1.1-Apache-2.0", "type": "module", diff --git a/test-client/CHANGELOG.md b/test-client/CHANGELOG.md index 21411c998..bbabdc868 100644 --- a/test-client/CHANGELOG.md +++ b/test-client/CHANGELOG.md @@ -1,5 +1,12 @@ # test-client +## 0.1.10 + +### Patch Changes + +- Updated dependencies [21de621] + - @powersync/service-core@0.8.8 + ## 0.1.9 ### Patch Changes diff --git a/test-client/package.json b/test-client/package.json index 10bc1b69a..9bba6da39 100644 --- a/test-client/package.json +++ b/test-client/package.json @@ -2,7 +2,7 @@ "name": "test-client", "repository": "https://github.com/powersync-ja/powersync-service", "private": true, - "version": "0.1.9", + "version": "0.1.10", "main": "dist/index.js", "bin": "dist/bin.js", "license": "Apache-2.0", From 7b168a238d9e4a76332a78663e757a481889dd06 Mon Sep 17 00:00:00 2001 From: Ralf Kistner Date: Thu, 14 Nov 2024 09:41:31 +0200 Subject: [PATCH 3/3] Fix tests. --- .../src/routes/probes.integration.test.ts | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/packages/service-core/test/src/routes/probes.integration.test.ts b/packages/service-core/test/src/routes/probes.integration.test.ts index 5b1b3e276..7a3419e70 100644 --- a/packages/service-core/test/src/routes/probes.integration.test.ts +++ b/packages/service-core/test/src/routes/probes.integration.test.ts @@ -6,8 +6,8 @@ import * as system from '../../../src/system/system-index.js'; import { configureFastifyServer } from '../../../src/index.js'; import { ProbeRoutes } from '../../../src/routes/endpoints/probes.js'; -vi.mock("@powersync/lib-services-framework", async () => { - const actual = await vi.importActual("@powersync/lib-services-framework") as any; +vi.mock('@powersync/lib-services-framework', async () => { + const actual = (await vi.importActual('@powersync/lib-services-framework')) as any; return { ...actual, container: { @@ -15,18 +15,18 @@ vi.mock("@powersync/lib-services-framework", async () => { probes: { state: vi.fn() } - }, - } -}) + } + }; +}); describe('Probe Routes Integration', () => { let app: FastifyInstance; - let mockSystem: system.CorePowerSyncSystem; + let mockSystem: system.ServiceContext; beforeEach(async () => { app = Fastify(); - mockSystem = {} as system.CorePowerSyncSystem; - await configureFastifyServer(app, { system: mockSystem }); + mockSystem = { routerEngine: {} } as system.ServiceContext; + await configureFastifyServer(app, { service_context: mockSystem }); await app.ready(); }); @@ -46,7 +46,7 @@ describe('Probe Routes Integration', () => { const response = await app.inject({ method: 'GET', - url: ProbeRoutes.STARTUP, + url: ProbeRoutes.STARTUP }); expect(response.statusCode).toBe(200); @@ -67,7 +67,7 @@ describe('Probe Routes Integration', () => { const response = await app.inject({ method: 'GET', - url: ProbeRoutes.STARTUP, + url: ProbeRoutes.STARTUP }); expect(response.statusCode).toBe(400); @@ -90,7 +90,7 @@ describe('Probe Routes Integration', () => { const response = await app.inject({ method: 'GET', - url: ProbeRoutes.LIVENESS, + url: ProbeRoutes.LIVENESS }); expect(response.statusCode).toBe(200); @@ -111,7 +111,7 @@ describe('Probe Routes Integration', () => { const response = await app.inject({ method: 'GET', - url: ProbeRoutes.LIVENESS, + url: ProbeRoutes.LIVENESS }); expect(response.statusCode).toBe(400); @@ -134,7 +134,7 @@ describe('Probe Routes Integration', () => { const response = await app.inject({ method: 'GET', - url: ProbeRoutes.READINESS, + url: ProbeRoutes.READINESS }); expect(response.statusCode).toBe(200); @@ -155,7 +155,7 @@ describe('Probe Routes Integration', () => { const response = await app.inject({ method: 'GET', - url: ProbeRoutes.READINESS, + url: ProbeRoutes.READINESS }); expect(response.statusCode).toBe(400); @@ -172,17 +172,19 @@ describe('Probe Routes Integration', () => { vi.mocked(container.probes.state).mockReturnValue(mockState); // Create array of 15 concurrent requests (default concurrency is 10) - const requests = Array(15).fill(null).map(() => - app.inject({ - method: 'GET', - url: ProbeRoutes.STARTUP, - }) - ); + const requests = Array(15) + .fill(null) + .map(() => + app.inject({ + method: 'GET', + url: ProbeRoutes.STARTUP + }) + ); const responses = await Promise.all(requests); // All requests should complete successfully - responses.forEach(response => { + responses.forEach((response) => { expect(response.statusCode).toBe(200); expect(JSON.parse(response.payload)).toEqual({ ...mockState, @@ -196,18 +198,20 @@ describe('Probe Routes Integration', () => { vi.mocked(container.probes.state).mockReturnValue(mockState); // Create array of 35 concurrent requests (default max_queue_depth is 20) - const requests = Array(35).fill(null).map(() => - app.inject({ - method: 'GET', - url: ProbeRoutes.STARTUP, - }) - ); + const requests = Array(35) + .fill(null) + .map(() => + app.inject({ + method: 'GET', + url: ProbeRoutes.STARTUP + }) + ); const responses = await Promise.all(requests); // Some requests should succeed and some should fail with 429 - const successCount = responses.filter(r => r.statusCode === 200).length; - const queueFullCount = responses.filter(r => r.statusCode === 429).length; + const successCount = responses.filter((r) => r.statusCode === 200).length; + const queueFullCount = responses.filter((r) => r.statusCode === 429).length; expect(successCount).toBeGreaterThan(0); expect(queueFullCount).toBeGreaterThan(0); @@ -222,7 +226,7 @@ describe('Probe Routes Integration', () => { const response = await app.inject({ method: 'GET', - url: ProbeRoutes.STARTUP, + url: ProbeRoutes.STARTUP }); expect(response.headers['content-type']).toMatch(/application\/json/);