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/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); + }); + }); +}); 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",