From 9a24a13b9a24b1a52aee0cc5a59bd157e07edca9 Mon Sep 17 00:00:00 2001 From: netanelC Date: Mon, 1 Jun 2026 08:22:10 +0300 Subject: [PATCH 1/7] feat: add polls for detecting changes --- README.md | 35 +++++++-- src/config.ts | 97 ++++++++++++++++-------- src/httpClient.ts | 23 ++++-- src/options.ts | 2 + src/rollout/ChangeDetector.ts | 56 ++++++++++++++ src/types.ts | 17 ++++- tests/config.spec.ts | 2 + tests/httpClient.spec.ts | 2 +- tests/options.spec.ts | 1 + tests/rollout.spec.ts | 139 ++++++++++++++++++++++++++++++++++ 10 files changed, 329 insertions(+), 45 deletions(-) create mode 100644 src/rollout/ChangeDetector.ts create mode 100644 tests/rollout.spec.ts diff --git a/README.md b/README.md index bd11ae9..16b0553 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,12 @@ const configInstance = await config({ configServerUrl: 'http://localhost:8080', schema: commonBoilerplateV4, version: 'latest', - offlineMode: false + offlineMode: false, + pollIntervalMs: 30000, + onChange: (updatedConfig) => { + console.log('Configuration updated:', updatedConfig); + // Re-initialize DB connections, etc. + } }); const port = configInstance.get('server.port'); @@ -36,26 +41,26 @@ This section describes the API provided by the package for interacting with the ### `ConfigInstance` -The `ConfigInstance` interface represents the your way to interact with the configuration. It provides methods to retrieve configuration values and parts. +The `ConfigInstance` interface represents your way to interact with the configuration. When hot-reloading is enabled, this instance acts as a **live state machine**, updating its internal state dynamically. `T` is the typescript type associated with the chosen schema. it can be imported from the `@map-colonies/schemas` package. #### Methods ##### `get(path: TPath): _.GetFieldType` -- **Description**: Retrieves the value at the specified path from the configuration object. Note that the type of returned object is based on the path in the schema. +- **Description**: Retrieves the value at the specified path from the configuration object. If hot-reloading is active, this returns the value from the **most recent** configuration update. - **Parameters**: - `path` (`TPath`): The path to the desired value. - **Returns**: The value at the specified path. ##### `getAll(): T` -- **Description**: Retrieves the entire configuration object. +- **Description**: Retrieves the entire configuration object. If hot-reloading is active, this returns the **most recent** configuration state. - **Returns**: The entire configuration object. ##### `getConfigParts(): { localConfig: object; config: object; envConfig: object }` -- **Description**: Retrieves different parts of the configuration object before being merged and validated. Useful for debugging. +- **Description**: Retrieves different parts of the configuration object before being merged and validated. If hot-reloading is active, the `config` part reflects the **latest remote payload**. - **Returns**: An object containing the `localConfig`, `config`, and `envConfig` parts of the configuration. - `localConfig`: The local configuration object. - `config`: The remote configuration object. @@ -121,6 +126,18 @@ This package allows you to configure various options for loading and managing co - **Default**: `./config` - **Description**: The path to the local configuration folder. +### `pollIntervalMs` +- **Type**: `number` +- **Optional**: `true` +- **Default**: `30000` +- **Description**: The polling interval in milliseconds for hot-reloading. +- **Environment Variable**: `CONFIG_POLL_INTERVAL_MS` + +### `onChange` +- **Type**: `(config: T) => void | Promise` +- **Optional**: `true` +- **Description**: A callback function triggered when a configuration change is detected. + ## Environment Variable Configuration The following environment variables can be used to configure the options: @@ -130,6 +147,7 @@ The following environment variables can be used to configure the options: - `CONFIG_SERVER_URL`: Sets the `configServerUrl` option. - `CONFIG_OFFLINE_MODE`: Sets the `offlineMode` option. - `CONFIG_IGNORE_SERVER_IS_OLDER_VERSION_ERROR`: Sets the `ignoreServerIsOlderVersionError` option. +- `CONFIG_POLL_INTERVAL_MS`: Sets the `pollIntervalMs` option. ## Configuration Merging and Validation @@ -143,6 +161,7 @@ The package supports merging configurations from multiple sources (local, remote 1. The remote configuration is fetched from the server specified by the `configServerUrl` option. 2. If the `version` is set to `'latest'`, the latest version of the configuration is fetched. Otherwise, the specified version is fetched. +3. **Continuous Polling:** If an `onChange` callback is provided, the SDK continuously polls the server using HTTP ETags (`If-None-Match`). When a `200 OK` is received (indicating a change), the configuration is automatically re-merged, validated, and the callback is triggered. `304 Not Modified` responses are silently ignored. ### Environment Variables @@ -158,13 +177,15 @@ If the value of the `x-env-format` key is `json`, the environment variable value - Local configuration 2. If a configuration option is specified in multiple sources, the value from the source with higher precedence (as listed above) is used. +3. When a hot-reload occurs, the **Remote configuration** part is updated, and the merge is re-calculated, ensuring environment variables still win. ### Validation -1. After merging, the final configuration is validated against the defined schema using ajv. +1. After merging (and after every hot-reload), the final configuration is validated against the defined schema using ajv. 2. The validation ensures that all required properties are present, and the types and values of properties conform to the schema. 3. Any default value according to the schema is added to the final object. -4. If the validation fails, an error is thrown, indicating the invalid properties and their issues. +4. If the validation fails, an error is thrown (for initial boot) or logged (for background updates), indicating the invalid properties and their issues. +5. **Atomic Updates:** The `ConfigInstance` only updates its internal state if the new configuration passes validation. If validation fails during a hot-reload, the previous valid state is preserved. # Error handling diff --git a/src/config.ts b/src/config.ts index 625558e..727843f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,7 @@ import { LOCAL_SCHEMAS_PACKAGE_VERSION } from './constants'; import { createConfigError } from './errors'; import { initializeMetrics as initializeMetricsInternal } from './metrics'; import { deepFreeze } from './utils/helpers'; +import { ChangeDetector } from './rollout/ChangeDetector'; const debug = createDebug('config'); @@ -25,22 +26,56 @@ const semverSatisfies = '2.x'; /** * Retrieves the configuration based on the provided options. * + * If `onChange` is provided in the options and `offlineMode` is not enabled, the SDK starts a background + * polling mechanism. The returned `ConfigInstance` serves as a live state machine; its `get` and `getAll` + * methods will return the most recent configuration retrieved from the server during hot-reloads. + * * @template T - The type of the configuration schema. * @param {ConfigOptions} options - The options for retrieving the configuration. - * @returns {Promise>} - A promise that resolves to the configuration object. + * @returns {Promise>} - A promise that resolves to the configuration object. */ export async function config( options: ConfigOptions ): Promise> { // handle package options debug('config called with options: %j', { ...options, schema: options.schema.$id }); - const { schema: baseSchema, metricsRegistry, ...unvalidatedOptions } = options; - const { configName, offlineMode, version, ignoreServerIsOlderVersionError } = initializeOptions(unvalidatedOptions); + const { schema: baseSchema, metricsRegistry, onChange, ...unvalidatedOptions } = options; + const initOptions = initializeOptions(unvalidatedOptions); + const { configName, offlineMode, version, ignoreServerIsOlderVersionError } = initOptions; - let remoteConfig: object | T = {}; + // Load Local and Env Configs First (Independent of remote state) + const dereferencedSchema = await loadSchema(baseSchema); + const localConfig = configPkg.util.loadFileConfigs(options.localConfigPath) as { [key: string]: unknown }; + debug('local config: %j', localConfig); + const envConfig = getEnvValues(dereferencedSchema); + debug('env config: %j', envConfig); + /** + * Function to merge local, remote, and environment configs and validate the merged result against the schema. + * The precedence order for merging is: local < remote < environment. + */ + function mergeAndValidate(remoteConfig: object | T): unknown { + const mergedConfig = deepmerge.all([localConfig, remoteConfig, envConfig], { arrayMerge }); + debug('merged config: %j', mergedConfig); + + // validate the merged config + const [errors, validatedConfig] = validate(ajvConfigValidator, dereferencedSchema, mergedConfig); + if (errors) { + debug('config validation error: %j', errors); + throw createConfigError('configValidationError', 'Config validation error', errors); + } + + debug('freezing validated config'); + // freeze the merged config so it can't be modified by the package user and to ensure that the config instance always returns the same reference for the same config, which is important for change detection and to prevent unnecessary re-renders in case the config is used in a React application and the user is using the getConfigParts method to get the config and pass it to their components + deepFreeze(validatedConfig); + return validatedConfig; + } + + let remoteConfig: object | T = {}; let serverConfigResponse: Config | undefined = undefined; - // handle remote config + let validatedConfig: ReturnType; + + // Handle Remote Config and Polling if (offlineMode !== true) { debug('handling fetching remote data'); // check if the server is using an older version of the schemas package @@ -70,8 +105,10 @@ export async function config( ); } - // get the remote config - serverConfigResponse = await getRemoteConfig(configName, options.schema.$id, version); + // get the initial remote config + const remoteResponse = await getRemoteConfig(configName, options.schema.$id, version); + serverConfigResponse = remoteResponse.config!; + const currentEtag = remoteResponse.etag; if (serverConfigResponse.schemaId !== baseSchema.$id) { debug('schema version mismatch. local: %s, remote: %s', baseSchema.$id, serverConfigResponse.schemaId); @@ -86,31 +123,29 @@ export async function config( } remoteConfig = serverConfigResponse.config; + debug('remote config: %j', remoteConfig); + + validatedConfig = mergeAndValidate(remoteConfig); + + // Setup polling + if (onChange) { + const changeDetector = new ChangeDetector( + baseSchema.$id, + initOptions, + async (newRemoteConfig: object) => { + const newlyValidatedConfig = mergeAndValidate(newRemoteConfig); + validatedConfig = newlyValidatedConfig; + remoteConfig = newRemoteConfig; + await onChange(newlyValidatedConfig); + }, + currentEtag + ); + changeDetector.start(); + } + } else { + // If offline, bypass remote and just merge local/env + validatedConfig = mergeAndValidate({}); } - debug('remote config: %j', remoteConfig); - - const dereferencedSchema = await loadSchema(baseSchema); - - const localConfig = configPkg.util.loadFileConfigs(options.localConfigPath) as { [key: string]: unknown }; - debug('local config: %j', localConfig); - - const envConfig = getEnvValues(dereferencedSchema); - debug('env config: %j', envConfig); - - // merge all the configs into one object with the following priority: localConfig < remoteConfig < envConfig - const mergedConfig = deepmerge.all([localConfig, remoteConfig, envConfig], { arrayMerge }); - debug('merged config: %j', mergedConfig); - - // validate the merged config - const [errors, validatedConfig] = validate(ajvConfigValidator, dereferencedSchema, mergedConfig); - if (errors) { - debug('config validation error: %j', errors); - throw createConfigError('configValidationError', 'Config validation error', errors); - } - - debug('freezing validated config'); - // freeze the merged config so it can't be modified by the package user - deepFreeze(validatedConfig); function get(path: TPath): GetFieldType { debug('get called with path: %s', path); diff --git a/src/httpClient.ts b/src/httpClient.ts index eef4279..e3f5053 100644 --- a/src/httpClient.ts +++ b/src/httpClient.ts @@ -15,10 +15,10 @@ async function createHttpErrorPayload(res: Dispatcher.ResponseData): Promise): Promise { +async function requestWrapper(url: string, options: Parameters>[1] = undefined): Promise { debug('Making request to %s', url); try { - const res = await request(url, { query }); + const res = await request(url, options); if (res.statusCode > statusCodes.NOT_FOUND) { debug('Failed to fetch config. Status code: %d', res.statusCode); throw createConfigError('httpResponseError', 'Failed to fetch', await createHttpErrorPayload(res)); @@ -33,12 +33,25 @@ async function requestWrapper(url: string, query?: Record): Pro } } -export async function getRemoteConfig(configName: string, schemaId: string, version: number | 'latest'): Promise { +export async function getRemoteConfig( + configName: string, + schemaId: string, + version: number | 'latest', + etag?: string +): Promise<{ config: Config | null; etag: string }> { debug('Fetching remote config %s@%s', configName, version); const { configServerUrl } = getOptions(); const url = `${configServerUrl}/config/${configName}/${version}`; - const res = await requestWrapper(url, { shouldDereference: true, schemaId }); + const headers = etag !== undefined ? { 'If-None-Match': etag } : undefined; + const queryParams = { schemaId, shouldDereference: 'true' }; + + const res = await requestWrapper(url, { query: queryParams, headers }); + + if (res.statusCode === statusCodes.NOT_MODIFIED) { + debug('Config was not modified'); + return { config: null, etag: etag! }; + } if (res.statusCode === statusCodes.BAD_REQUEST) { debug('Invalid request to getConfig'); @@ -51,7 +64,7 @@ export async function getRemoteConfig(configName: string, schemaId: string, vers } debug('Config fetched successfully'); - return (await res.body.json()) as Config; + return { config: (await res.body.json()) as Config, etag: res.headers.etag as string }; } export async function getServerCapabilities(): Promise { diff --git a/src/options.ts b/src/options.ts index 6ef024d..81459fe 100644 --- a/src/options.ts +++ b/src/options.ts @@ -11,6 +11,7 @@ const defaultOptions: BaseOptions = { configName: PACKAGE_NAME, configServerUrl: 'http://localhost:8080', version: 'latest', + pollIntervalMs: 30000, }; const envOptions: Partial> = { @@ -19,6 +20,7 @@ const envOptions: Partial> = { version: process.env.CONFIG_VERSION, offlineMode: process.env.CONFIG_OFFLINE_MODE, ignoreServerIsOlderVersionError: process.env.CONFIG_IGNORE_SERVER_IS_OLDER_VERSION_ERROR, + pollIntervalMs: process.env.CONFIG_POLL_INTERVAL_MS, }; // in order to merge correctly the keys should not exist, undefined is not enough diff --git a/src/rollout/ChangeDetector.ts b/src/rollout/ChangeDetector.ts new file mode 100644 index 0000000..06fa814 --- /dev/null +++ b/src/rollout/ChangeDetector.ts @@ -0,0 +1,56 @@ +import { getRemoteConfig } from '../httpClient'; +import { BaseOptions } from '../types'; +import { createDebug } from '../utils/debug'; + +const debug = createDebug('changeDetector'); + +/** + * Class responsible for detecting changes in the remote configuration by periodically polling the server and comparing ETags. + * If a change is detected, it invokes the provided callback with the new configuration. + */ +export class ChangeDetector { + private currentEtag: string; + private timer?: NodeJS.Timeout; + + public constructor( + private readonly schemaId: string, + private readonly options: BaseOptions, + private readonly onConfigUpdate: (newRemoteConfig: object) => void | Promise, + initialEtag: string + ) { + this.currentEtag = initialEtag; + } + + public start(): void { + const interval = this.options.pollIntervalMs; + debug('Starting change detector with interval %d ms', interval); + + this.timer = setInterval(() => { + this.poll().catch((err) => { + debug('Error during polling: %s', (err as Error).message); + }); + }, interval); + } + + public stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + private async poll(): Promise { + debug('Polling config %s@%s with etag %s', this.options.configName, this.options.version, this.currentEtag); + + const response = await getRemoteConfig(this.options.configName, this.schemaId, this.options.version, this.currentEtag); + + if (response.config === null) { + debug('No config changes detected'); + return; + } + + debug('Config change detected'); + this.currentEtag = response.etag!; + await this.onConfigUpdate(response.config.config); + } +} diff --git a/src/types.ts b/src/types.ts index 8854437..6e04eda 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,6 +66,11 @@ export interface BaseOptions { * @default './config' */ localConfigPath?: string; + /** + * The polling interval in milliseconds. + * @default 30000 + */ + pollIntervalMs?: number; } /** @@ -82,6 +87,10 @@ export type ConfigOptions = Prettify< * Depends on the prom-client package being installed. */ metricsRegistry?: Registry; + /** + * The callback function that is triggered when the configuration changes. + */ + onChange?: (config: unknown) => void | Promise; } >; @@ -101,16 +110,20 @@ export const optionsSchema: JSONSchemaType = { offlineMode: { type: 'boolean', nullable: true }, ignoreServerIsOlderVersionError: { type: 'boolean', nullable: true }, localConfigPath: { type: 'string', default: './config', nullable: true }, + pollIntervalMs: { type: 'integer', default: 30000, nullable: true }, }, }; /** - * Represents the schema of the configuration object. + * Represents a live configuration instance. + * When hot-reloading is enabled, this instance acts as a state machine that updates its internal + * configuration state dynamically. * @template T - The type of the configuration schema. */ export interface ConfigInstance { /** * Retrieves the value at the specified path from the configuration object. + * If hot-reloading is active, this returns the value from the most recent configuration update. * @template TPath - The type of the path. * @param path - The path to the desired value. * @returns The value at the specified path. @@ -119,12 +132,14 @@ export interface ConfigInstance { /** * Retrieves the entire configuration object. + * If hot-reloading is active, this returns the most recent configuration state. * @returns The entire configuration object. */ getAll: () => T; /** * Retrieves different parts of the configuration object before being merged and validated. + * If hot-reloading is active, 'config' reflects the latest remote payload. * @returns An object containing the localConfig, config, and envConfig parts of the configuration. */ getConfigParts: () => { diff --git a/tests/config.spec.ts b/tests/config.spec.ts index 8dbb0de..fb4b411 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -210,6 +210,7 @@ describe('config', () => { configServerUrl: URL, localConfigPath: './tests/config', offlineMode: true, + pollIntervalMs: 3000, }); const options = configInstance.getResolvedOptions(); @@ -220,6 +221,7 @@ describe('config', () => { configServerUrl: URL, localConfigPath: './tests/config', offlineMode: true, + pollIntervalMs: 3000, }); }); diff --git a/tests/httpClient.spec.ts b/tests/httpClient.spec.ts index fbb7772..61af7c8 100644 --- a/tests/httpClient.spec.ts +++ b/tests/httpClient.spec.ts @@ -62,7 +62,7 @@ describe('httpClient', () => { client.intercept({ path: '/config/name/1?shouldDereference=true&schemaId=schema', method: 'GET' }).reply(StatusCodes.OK, config); const result = await getRemoteConfig('name', 'schema', 1); - expect(result).toEqual(config); + expect(result).toEqual({ config: config, etag: undefined }); }); it('should throw an error if the response is bad request', async () => { diff --git a/tests/options.spec.ts b/tests/options.spec.ts index b228eae..2fbaf93 100644 --- a/tests/options.spec.ts +++ b/tests/options.spec.ts @@ -73,6 +73,7 @@ describe('options', () => { ['version', 'CONFIG_VERSION', 'latest', 'latest'], ['offlineMode', 'CONFIG_OFFLINE_MODE', 'true', true], ['ignoreServerIsOlderVersionError', 'CONFIG_IGNORE_SERVER_IS_OLDER_VERSION_ERROR', 'true', true], + ['pollIntervalMs', 'CONFIG_POLL_INTERVAL_MS', '10000', 10000], ])('should initialize options and override with provided environment variable %s', async (key, envKey, envValue, expected) => { process.env[envKey] = envValue; diff --git a/tests/rollout.spec.ts b/tests/rollout.spec.ts new file mode 100644 index 0000000..5f741da --- /dev/null +++ b/tests/rollout.spec.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Interceptable, MockAgent, setGlobalDispatcher } from 'undici'; +import { commonDbPartialV1 } from '@map-colonies/schemas'; +import { StatusCodes } from 'http-status-codes'; +import { config } from '../src/config'; + +const URL = 'http://localhost:8080'; + +describe('Continuous Polling (ChangeDetector)', () => { + let client: Interceptable; + + beforeEach(() => { + vi.useFakeTimers(); + const agent = new MockAgent(); + agent.disableNetConnect(); + + setGlobalDispatcher(agent); + client = agent.get(URL); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should trigger onChange when polling returns a new config (200 OK)', async () => { + const initialConfigData = { + configName: 'name', + schemaId: commonDbPartialV1.$id, + version: 1, + config: { + host: 'initial-host', + }, + createdAt: 0, + }; + + const newConfigData = { + configName: 'name', + schemaId: commonDbPartialV1.$id, + version: 1, + config: { + host: 'updated-host', + }, + createdAt: 1, + }; + + // Initial capabilities fetch + client + .intercept({ path: '/capabilities', method: 'GET' }) + .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); + + // Initial config fetch + client + .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) + .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); + + const onChangeMock = vi.fn(); + + const configInstance = await config({ + configName: 'name', + version: 1, + schema: commonDbPartialV1, + configServerUrl: URL, + localConfigPath: './tests/config', + pollIntervalMs: 10000, + onChange: onChangeMock, + }); + + expect(configInstance.get('host')).toBe('initial-host'); + expect(onChangeMock).not.toHaveBeenCalled(); + + // Setup next poll response + client + .intercept({ + path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, + method: 'GET', + headers: { 'if-none-match': 'etag-1' }, + }) + .reply(StatusCodes.OK, newConfigData, { headers: { etag: 'etag-2' } }); + + // Advance time to trigger poll + await vi.advanceTimersByTimeAsync(10000); + + expect(onChangeMock).toHaveBeenCalledTimes(1); + expect(onChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'updated-host', + }) + ); + }); + + it('should not trigger onChange when polling returns 304 Not Modified', async () => { + const initialConfigData = { + configName: 'name', + schemaId: commonDbPartialV1.$id, + version: 1, + config: { + host: 'initial-host', + }, + createdAt: 0, + }; + + // Initial capabilities fetch + client + .intercept({ path: '/capabilities', method: 'GET' }) + .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); + + // Initial config fetch + client + .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) + .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); + + const onChangeMock = vi.fn(); + + const configInstance = await config({ + configName: 'name', + version: 1, + schema: commonDbPartialV1, + configServerUrl: URL, + localConfigPath: './tests/config', + pollIntervalMs: 10000, + onChange: onChangeMock, + }); + + // Setup next poll response (304) + client + .intercept({ + path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, + method: 'GET', + headers: { 'if-none-match': 'etag-1' }, + }) + .reply(StatusCodes.NOT_MODIFIED); + + // Advance time to trigger poll + await vi.advanceTimersByTimeAsync(10000); + + expect(onChangeMock).not.toHaveBeenCalled(); + expect(configInstance.get('host')).toBe('initial-host'); + }); +}); From 1d316b90e521786fe6645343ad267a3da3c02bbc Mon Sep 17 00:00:00 2001 From: netanelC Date: Mon, 1 Jun 2026 09:34:24 +0300 Subject: [PATCH 2/7] feat: add stop function --- README.md | 3 ++ src/config.ts | 12 +++++- src/types.ts | 5 +++ tests/rollout.spec.ts | 88 +++++++++++++++++++++++++++++-------------- 4 files changed, 77 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 16b0553..2da1b2f 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,9 @@ The `ConfigInstance` interface represents your way to interact with the configur - **Parameters**: - `registry` (`promClient.Registry`): The prometheus registry to use for the metrics. +##### `stop(): void` +- **Description**: Stops any background processes (like hot-reloading polling). Use this during application teardown or in tests to prevent memory leaks and hanging processes. + # Configuration Options This package allows you to configure various options for loading and managing configurations. Below are the available options and their descriptions. diff --git a/src/config.ts b/src/config.ts index 727843f..967e4d3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -71,6 +71,7 @@ export async function config( return validatedConfig; } + let changeDetector: ChangeDetector | undefined = undefined; let remoteConfig: object | T = {}; let serverConfigResponse: Config | undefined = undefined; let validatedConfig: ReturnType; @@ -129,7 +130,7 @@ export async function config( // Setup polling if (onChange) { - const changeDetector = new ChangeDetector( + changeDetector = new ChangeDetector( baseSchema.$id, initOptions, async (newRemoteConfig: object) => { @@ -175,5 +176,12 @@ export async function config( initializeMetricsInternal(registry, baseSchema.$id, serverConfigResponse?.version); } - return { get, getAll, getConfigParts, getResolvedOptions, initializeMetrics }; + function stop(): void { + debug('stop called'); + if (changeDetector) { + changeDetector.stop(); + } + } + + return { get, getAll, getConfigParts, getResolvedOptions, initializeMetrics, stop }; } diff --git a/src/types.ts b/src/types.ts index 6e04eda..e6c4eaa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -159,4 +159,9 @@ export interface ConfigInstance { * @param registry - The registry for the metrics. */ initializeMetrics: (registry: Registry) => void; + + /** + * Stops any background processes (like hot-reloading polling). + */ + stop: () => void; } diff --git a/tests/rollout.spec.ts b/tests/rollout.spec.ts index 5f741da..abde307 100644 --- a/tests/rollout.spec.ts +++ b/tests/rollout.spec.ts @@ -5,6 +5,7 @@ import { StatusCodes } from 'http-status-codes'; import { config } from '../src/config'; const URL = 'http://localhost:8080'; +const DEFAULT_POLL_INTERVAL = 10000; describe('Continuous Polling (ChangeDetector)', () => { let client: Interceptable; @@ -23,52 +24,47 @@ describe('Continuous Polling (ChangeDetector)', () => { }); it('should trigger onChange when polling returns a new config (200 OK)', async () => { + // Arrange const initialConfigData = { configName: 'name', schemaId: commonDbPartialV1.$id, version: 1, - config: { - host: 'initial-host', - }, + config: { host: 'initial-host' }, createdAt: 0, }; - const newConfigData = { configName: 'name', schemaId: commonDbPartialV1.$id, version: 1, - config: { - host: 'updated-host', - }, + config: { host: 'updated-host' }, createdAt: 1, }; - // Initial capabilities fetch client .intercept({ path: '/capabilities', method: 'GET' }) .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); - - // Initial config fetch client .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); const onChangeMock = vi.fn(); + // Act const configInstance = await config({ configName: 'name', version: 1, schema: commonDbPartialV1, configServerUrl: URL, localConfigPath: './tests/config', - pollIntervalMs: 10000, + pollIntervalMs: DEFAULT_POLL_INTERVAL, onChange: onChangeMock, }); + // Assert (Initial State) expect(configInstance.get('host')).toBe('initial-host'); expect(onChangeMock).not.toHaveBeenCalled(); - // Setup next poll response + // Arrange (Next Poll) client .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, @@ -77,51 +73,45 @@ describe('Continuous Polling (ChangeDetector)', () => { }) .reply(StatusCodes.OK, newConfigData, { headers: { etag: 'etag-2' } }); - // Advance time to trigger poll - await vi.advanceTimersByTimeAsync(10000); + // Act (Wait for Poll) + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); + // Assert (Updated State) expect(onChangeMock).toHaveBeenCalledTimes(1); - expect(onChangeMock).toHaveBeenCalledWith( - expect.objectContaining({ - host: 'updated-host', - }) - ); + expect(onChangeMock).toHaveBeenCalledWith(expect.objectContaining({ host: 'updated-host' })); }); it('should not trigger onChange when polling returns 304 Not Modified', async () => { + // Arrange const initialConfigData = { configName: 'name', schemaId: commonDbPartialV1.$id, version: 1, - config: { - host: 'initial-host', - }, + config: { host: 'initial-host' }, createdAt: 0, }; - // Initial capabilities fetch client .intercept({ path: '/capabilities', method: 'GET' }) .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); - - // Initial config fetch client .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); const onChangeMock = vi.fn(); + // Act const configInstance = await config({ configName: 'name', version: 1, schema: commonDbPartialV1, configServerUrl: URL, localConfigPath: './tests/config', - pollIntervalMs: 10000, + pollIntervalMs: DEFAULT_POLL_INTERVAL, onChange: onChangeMock, }); - // Setup next poll response (304) + // Arrange (Setup 304 response) client .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, @@ -130,10 +120,50 @@ describe('Continuous Polling (ChangeDetector)', () => { }) .reply(StatusCodes.NOT_MODIFIED); - // Advance time to trigger poll - await vi.advanceTimersByTimeAsync(10000); + // Act (Wait for Poll) + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); + // Assert expect(onChangeMock).not.toHaveBeenCalled(); expect(configInstance.get('host')).toBe('initial-host'); }); + + it('should stop polling when stop is called', async () => { + // Arrange + const initialConfigData = { + configName: 'name', + schemaId: commonDbPartialV1.$id, + version: 1, + config: { host: 'initial-host' }, + createdAt: 0, + }; + + client + .intercept({ path: '/capabilities', method: 'GET' }) + .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); + client + .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) + .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); + + const onChangeMock = vi.fn(); + + const configInstance = await config({ + configName: 'name', + version: 1, + schema: commonDbPartialV1, + configServerUrl: URL, + localConfigPath: './tests/config', + pollIntervalMs: DEFAULT_POLL_INTERVAL, + onChange: onChangeMock, + }); + + // Act + configInstance.stop(); + + // Advance time beyond the polling interval + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL * 2); + + // Assert + expect(onChangeMock).not.toHaveBeenCalled(); + }); }); From 2956d54978d2c52c86197e2b1021dc7fe044bf9a Mon Sep 17 00:00:00 2001 From: netanelC Date: Thu, 4 Jun 2026 19:06:24 +0300 Subject: [PATCH 3/7] feat: terminate the process regardless to the new config --- README.md | 23 +++---- src/config.ts | 18 +---- src/rollout/ChangeDetector.ts | 17 +++-- src/types.ts | 2 +- tests/rollout.spec.ts | 119 +++++++++++++++++++++++++++++++++- 5 files changed, 143 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 2da1b2f..2657a41 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,8 @@ const configInstance = await config({ version: 'latest', offlineMode: false, pollIntervalMs: 30000, - onChange: (updatedConfig) => { - console.log('Configuration updated:', updatedConfig); - // Re-initialize DB connections, etc. + onChange: () => { + console.log('Configuration changed! The process will now restart...'); } }); @@ -41,26 +40,26 @@ This section describes the API provided by the package for interacting with the ### `ConfigInstance` -The `ConfigInstance` interface represents your way to interact with the configuration. When hot-reloading is enabled, this instance acts as a **live state machine**, updating its internal state dynamically. -`T` is the typescript type associated with the chosen schema. it can be imported from the `@map-colonies/schemas` package. +The `ConfigInstance` interface represents your way to interact with the configuration. +`T` is the typescript type associated with the chosen schema. It can be imported from the `@map-colonies/schemas` package. #### Methods ##### `get(path: TPath): _.GetFieldType` -- **Description**: Retrieves the value at the specified path from the configuration object. If hot-reloading is active, this returns the value from the **most recent** configuration update. +- **Description**: Retrieves the value at the specified path from the configuration object. - **Parameters**: - `path` (`TPath`): The path to the desired value. - **Returns**: The value at the specified path. ##### `getAll(): T` -- **Description**: Retrieves the entire configuration object. If hot-reloading is active, this returns the **most recent** configuration state. +- **Description**: Retrieves the entire configuration object. - **Returns**: The entire configuration object. ##### `getConfigParts(): { localConfig: object; config: object; envConfig: object }` -- **Description**: Retrieves different parts of the configuration object before being merged and validated. If hot-reloading is active, the `config` part reflects the **latest remote payload**. +- **Description**: Retrieves different parts of the configuration object before being merged and validated. - **Returns**: An object containing the `localConfig`, `config`, and `envConfig` parts of the configuration. - `localConfig`: The local configuration object. - `config`: The remote configuration object. @@ -164,7 +163,7 @@ The package supports merging configurations from multiple sources (local, remote 1. The remote configuration is fetched from the server specified by the `configServerUrl` option. 2. If the `version` is set to `'latest'`, the latest version of the configuration is fetched. Otherwise, the specified version is fetched. -3. **Continuous Polling:** If an `onChange` callback is provided, the SDK continuously polls the server using HTTP ETags (`If-None-Match`). When a `200 OK` is received (indicating a change), the configuration is automatically re-merged, validated, and the callback is triggered. `304 Not Modified` responses are silently ignored. +3. **Continuous Polling:** The SDK continuously polls the server using HTTP ETags (`If-None-Match`). When a `200 OK` is received (indicating a change), the `onChange` callback is triggered (if provided), and the process is then terminated to allow for a fresh start with the new configuration. `304 Not Modified` responses are silently ignored. ### Environment Variables @@ -180,15 +179,13 @@ If the value of the `x-env-format` key is `json`, the environment variable value - Local configuration 2. If a configuration option is specified in multiple sources, the value from the source with higher precedence (as listed above) is used. -3. When a hot-reload occurs, the **Remote configuration** part is updated, and the merge is re-calculated, ensuring environment variables still win. ### Validation -1. After merging (and after every hot-reload), the final configuration is validated against the defined schema using ajv. +1. After merging, the final configuration is validated against the defined schema using ajv. 2. The validation ensures that all required properties are present, and the types and values of properties conform to the schema. 3. Any default value according to the schema is added to the final object. -4. If the validation fails, an error is thrown (for initial boot) or logged (for background updates), indicating the invalid properties and their issues. -5. **Atomic Updates:** The `ConfigInstance` only updates its internal state if the new configuration passes validation. If validation fails during a hot-reload, the previous valid state is preserved. +4. If the validation fails, an error is thrown (for initial boot). # Error handling diff --git a/src/config.ts b/src/config.ts index 967e4d3..386756c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,7 +26,7 @@ const semverSatisfies = '2.x'; /** * Retrieves the configuration based on the provided options. * - * If `onChange` is provided in the options and `offlineMode` is not enabled, the SDK starts a background + * If `offlineMode` is not enabled, the SDK starts a background * polling mechanism. The returned `ConfigInstance` serves as a live state machine; its `get` and `getAll` * methods will return the most recent configuration retrieved from the server during hot-reloads. * @@ -129,20 +129,8 @@ export async function config( validatedConfig = mergeAndValidate(remoteConfig); // Setup polling - if (onChange) { - changeDetector = new ChangeDetector( - baseSchema.$id, - initOptions, - async (newRemoteConfig: object) => { - const newlyValidatedConfig = mergeAndValidate(newRemoteConfig); - validatedConfig = newlyValidatedConfig; - remoteConfig = newRemoteConfig; - await onChange(newlyValidatedConfig); - }, - currentEtag - ); - changeDetector.start(); - } + changeDetector = new ChangeDetector(baseSchema.$id, initOptions, currentEtag, onChange); + changeDetector.start(); } else { // If offline, bypass remote and just merge local/env validatedConfig = mergeAndValidate({}); diff --git a/src/rollout/ChangeDetector.ts b/src/rollout/ChangeDetector.ts index 06fa814..5d0799a 100644 --- a/src/rollout/ChangeDetector.ts +++ b/src/rollout/ChangeDetector.ts @@ -9,14 +9,14 @@ const debug = createDebug('changeDetector'); * If a change is detected, it invokes the provided callback with the new configuration. */ export class ChangeDetector { - private currentEtag: string; + private readonly currentEtag: string; private timer?: NodeJS.Timeout; public constructor( private readonly schemaId: string, private readonly options: BaseOptions, - private readonly onConfigUpdate: (newRemoteConfig: object) => void | Promise, - initialEtag: string + initialEtag: string, + private readonly onConfigUpdate?: () => void | Promise ) { this.currentEtag = initialEtag; } @@ -50,7 +50,14 @@ export class ChangeDetector { } debug('Config change detected'); - this.currentEtag = response.etag!; - await this.onConfigUpdate(response.config.config); + try { + if (this.onConfigUpdate) { + await this.onConfigUpdate(); + } + } catch (err) { + debug('Error during onChange callback: %s', (err as Error).message); + } finally { + process.exit(0); + } } } diff --git a/src/types.ts b/src/types.ts index e6c4eaa..0750a4a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -90,7 +90,7 @@ export type ConfigOptions = Prettify< /** * The callback function that is triggered when the configuration changes. */ - onChange?: (config: unknown) => void | Promise; + onChange?: () => void | Promise; } >; diff --git a/tests/rollout.spec.ts b/tests/rollout.spec.ts index abde307..c0e0626 100644 --- a/tests/rollout.spec.ts +++ b/tests/rollout.spec.ts @@ -23,7 +23,7 @@ describe('Continuous Polling (ChangeDetector)', () => { vi.restoreAllMocks(); }); - it('should trigger onChange when polling returns a new config (200 OK)', async () => { + it('should trigger onChange and exit when polling returns a new config (200 OK)', async () => { // Arrange const initialConfigData = { configName: 'name', @@ -48,6 +48,9 @@ describe('Continuous Polling (ChangeDetector)', () => { .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); const onChangeMock = vi.fn(); + const exitMock = vi.spyOn(process, 'exit').mockImplementation(() => { + return undefined as never; + }); // Act const configInstance = await config({ @@ -78,7 +81,119 @@ describe('Continuous Polling (ChangeDetector)', () => { // Assert (Updated State) expect(onChangeMock).toHaveBeenCalledTimes(1); - expect(onChangeMock).toHaveBeenCalledWith(expect.objectContaining({ host: 'updated-host' })); + expect(onChangeMock).toHaveBeenCalledWith(); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + it('should exit when polling returns a new config (200 OK) and onChange is not provided', async () => { + // Arrange + const initialConfigData = { + configName: 'name', + schemaId: commonDbPartialV1.$id, + version: 1, + config: { host: 'initial-host' }, + createdAt: 0, + }; + const newConfigData = { + configName: 'name', + schemaId: commonDbPartialV1.$id, + version: 1, + config: { host: 'updated-host' }, + createdAt: 1, + }; + + client + .intercept({ path: '/capabilities', method: 'GET' }) + .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); + client + .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) + .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); + + const exitMock = vi.spyOn(process, 'exit').mockImplementation(() => { + return undefined as never; + }); + + // Act + await config({ + configName: 'name', + version: 1, + schema: commonDbPartialV1, + configServerUrl: URL, + localConfigPath: './tests/config', + pollIntervalMs: DEFAULT_POLL_INTERVAL, + }); + + // Arrange (Next Poll) + client + .intercept({ + path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, + method: 'GET', + headers: { 'if-none-match': 'etag-1' }, + }) + .reply(StatusCodes.OK, newConfigData, { headers: { etag: 'etag-2' } }); + + // Act (Wait for Poll) + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); + + // Assert + expect(exitMock).toHaveBeenCalledWith(0); + }); + + it('should exit even if onChange throws an error', async () => { + // Arrange + const initialConfigData = { + configName: 'name', + schemaId: commonDbPartialV1.$id, + version: 1, + config: { host: 'initial-host' }, + createdAt: 0, + }; + const newConfigData = { + configName: 'name', + schemaId: commonDbPartialV1.$id, + version: 1, + config: { host: 'updated-host' }, + createdAt: 1, + }; + + client + .intercept({ path: '/capabilities', method: 'GET' }) + .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); + client + .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) + .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); + + const onChangeMock = vi.fn().mockRejectedValue(new Error('onChange error')); + const exitMock = vi.spyOn(process, 'exit').mockImplementation(() => { + return undefined as never; + }); + + // Act + await config({ + configName: 'name', + version: 1, + schema: commonDbPartialV1, + configServerUrl: URL, + localConfigPath: './tests/config', + pollIntervalMs: DEFAULT_POLL_INTERVAL, + onChange: onChangeMock, + }); + + // Arrange (Next Poll) + client + .intercept({ + path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, + method: 'GET', + headers: { 'if-none-match': 'etag-1' }, + }) + .reply(StatusCodes.OK, newConfigData, { headers: { etag: 'etag-2' } }); + + // Act (Wait for Poll) + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); + + // Assert + expect(onChangeMock).toHaveBeenCalledTimes(1); + expect(exitMock).toHaveBeenCalledWith(0); }); it('should not trigger onChange when polling returns 304 Not Modified', async () => { From c65185a50305b716b1a6fd9bc3ec5c5676bbdd1f Mon Sep 17 00:00:00 2001 From: netanelC Date: Thu, 4 Jun 2026 19:25:47 +0300 Subject: [PATCH 4/7] fix: stop the poll on termination --- src/config.ts | 5 +++-- src/rollout/ChangeDetector.ts | 1 + src/types.ts | 7 +------ 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/config.ts b/src/config.ts index 386756c..ad7b993 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,8 +27,9 @@ const semverSatisfies = '2.x'; * Retrieves the configuration based on the provided options. * * If `offlineMode` is not enabled, the SDK starts a background - * polling mechanism. The returned `ConfigInstance` serves as a live state machine; its `get` and `getAll` - * methods will return the most recent configuration retrieved from the server during hot-reloads. + * polling mechanism. When a configuration change is detected, the SDK + * will execute the `onChange` callback (if provided) and then forcefully + * terminate the process (`process.exit(0)`), allowing Kubernetes to restart the pod. * * @template T - The type of the configuration schema. * @param {ConfigOptions} options - The options for retrieving the configuration. diff --git a/src/rollout/ChangeDetector.ts b/src/rollout/ChangeDetector.ts index 5d0799a..8c473d6 100644 --- a/src/rollout/ChangeDetector.ts +++ b/src/rollout/ChangeDetector.ts @@ -57,6 +57,7 @@ export class ChangeDetector { } catch (err) { debug('Error during onChange callback: %s', (err as Error).message); } finally { + this.stop(); process.exit(0); } } diff --git a/src/types.ts b/src/types.ts index 0750a4a..b250504 100644 --- a/src/types.ts +++ b/src/types.ts @@ -115,15 +115,12 @@ export const optionsSchema: JSONSchemaType = { }; /** - * Represents a live configuration instance. - * When hot-reloading is enabled, this instance acts as a state machine that updates its internal - * configuration state dynamically. + * Represents a configuration instance. * @template T - The type of the configuration schema. */ export interface ConfigInstance { /** * Retrieves the value at the specified path from the configuration object. - * If hot-reloading is active, this returns the value from the most recent configuration update. * @template TPath - The type of the path. * @param path - The path to the desired value. * @returns The value at the specified path. @@ -132,14 +129,12 @@ export interface ConfigInstance { /** * Retrieves the entire configuration object. - * If hot-reloading is active, this returns the most recent configuration state. * @returns The entire configuration object. */ getAll: () => T; /** * Retrieves different parts of the configuration object before being merged and validated. - * If hot-reloading is active, 'config' reflects the latest remote payload. * @returns An object containing the localConfig, config, and envConfig parts of the configuration. */ getConfigParts: () => { From 2362e41c09f672b994e332efccf0f2281411cfbf Mon Sep 17 00:00:00 2001 From: netanelC Date: Thu, 18 Jun 2026 13:15:47 +0300 Subject: [PATCH 5/7] feat: add terminatePod --- src/config.ts | 5 +- src/options.ts | 2 + src/rollout/ChangeDetector.ts | 21 +++++++-- src/types.ts | 6 +++ tests/config.spec.ts | 1 + tests/rollout.spec.ts | 88 +++++++++++------------------------ 6 files changed, 54 insertions(+), 69 deletions(-) diff --git a/src/config.ts b/src/config.ts index ad7b993..2e68865 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,8 +28,9 @@ const semverSatisfies = '2.x'; * * If `offlineMode` is not enabled, the SDK starts a background * polling mechanism. When a configuration change is detected, the SDK - * will execute the `onChange` callback (if provided) and then forcefully - * terminate the process (`process.exit(0)`), allowing Kubernetes to restart the pod. + * will execute the `onChange` callback (if provided). + * Depending on the `terminatePod` option (default `true`), it will either + * stop polling and wait for the user to terminate the process, or continue polling. * * @template T - The type of the configuration schema. * @param {ConfigOptions} options - The options for retrieving the configuration. diff --git a/src/options.ts b/src/options.ts index 81459fe..a40893b 100644 --- a/src/options.ts +++ b/src/options.ts @@ -12,6 +12,7 @@ const defaultOptions: BaseOptions = { configServerUrl: 'http://localhost:8080', version: 'latest', pollIntervalMs: 30000, + terminatePod: true, }; const envOptions: Partial> = { @@ -21,6 +22,7 @@ const envOptions: Partial> = { offlineMode: process.env.CONFIG_OFFLINE_MODE, ignoreServerIsOlderVersionError: process.env.CONFIG_IGNORE_SERVER_IS_OLDER_VERSION_ERROR, pollIntervalMs: process.env.CONFIG_POLL_INTERVAL_MS, + terminatePod: process.env.CONFIG_TERMINATE_POD, }; // in order to merge correctly the keys should not exist, undefined is not enough diff --git a/src/rollout/ChangeDetector.ts b/src/rollout/ChangeDetector.ts index 8c473d6..4c72cbe 100644 --- a/src/rollout/ChangeDetector.ts +++ b/src/rollout/ChangeDetector.ts @@ -1,6 +1,7 @@ import { getRemoteConfig } from '../httpClient'; import { BaseOptions } from '../types'; import { createDebug } from '../utils/debug'; +import { isConfigError } from '../errors'; const debug = createDebug('changeDetector'); @@ -9,7 +10,7 @@ const debug = createDebug('changeDetector'); * If a change is detected, it invokes the provided callback with the new configuration. */ export class ChangeDetector { - private readonly currentEtag: string; + private currentEtag: string; private timer?: NodeJS.Timeout; public constructor( @@ -49,16 +50,26 @@ export class ChangeDetector { return; } - debug('Config change detected'); + debug('Config change detected. Stopping polling. New etag: %s', response.etag); + this.stop(); try { if (this.onConfigUpdate) { await this.onConfigUpdate(); } } catch (err) { - debug('Error during onChange callback: %s', (err as Error).message); + if (isConfigError(err, 'httpResponseError') || isConfigError(err, 'httpGeneralError')) { + debug('Error during onChange callback: %s', err.message); + } else { + debug('Error during onChange callback: %s', err instanceof Error ? err.message : String(err)); + } } finally { - this.stop(); - process.exit(0); + if (this.options.terminatePod) { + debug('Pod termination is expected by the user.'); + } else { + this.currentEtag = response.etag; + debug('Pod termination is not expected.'); + this.start(); + } } } } diff --git a/src/types.ts b/src/types.ts index b250504..014da30 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,6 +71,11 @@ export interface BaseOptions { * @default 30000 */ pollIntervalMs?: number; + /** + * Indicates whether the pod will be terminated after an update. + * @default true + */ + terminatePod: boolean; } /** @@ -111,6 +116,7 @@ export const optionsSchema: JSONSchemaType = { ignoreServerIsOlderVersionError: { type: 'boolean', nullable: true }, localConfigPath: { type: 'string', default: './config', nullable: true }, pollIntervalMs: { type: 'integer', default: 30000, nullable: true }, + terminatePod: { type: 'boolean', default: true }, }, }; diff --git a/tests/config.spec.ts b/tests/config.spec.ts index fb4b411..13b7282 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -222,6 +222,7 @@ describe('config', () => { localConfigPath: './tests/config', offlineMode: true, pollIntervalMs: 3000, + terminatePod: true, }); }); diff --git a/tests/rollout.spec.ts b/tests/rollout.spec.ts index c0e0626..7bd6323 100644 --- a/tests/rollout.spec.ts +++ b/tests/rollout.spec.ts @@ -48,9 +48,6 @@ describe('Continuous Polling (ChangeDetector)', () => { .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); const onChangeMock = vi.fn(); - const exitMock = vi.spyOn(process, 'exit').mockImplementation(() => { - return undefined as never; - }); // Act const configInstance = await config({ @@ -82,10 +79,9 @@ describe('Continuous Polling (ChangeDetector)', () => { // Assert (Updated State) expect(onChangeMock).toHaveBeenCalledTimes(1); expect(onChangeMock).toHaveBeenCalledWith(); - expect(exitMock).toHaveBeenCalledWith(0); }); - it('should exit when polling returns a new config (200 OK) and onChange is not provided', async () => { + it('should trigger onChange and resume polling when polling returns a new config and terminatePod is false', async () => { // Arrange const initialConfigData = { configName: 'name', @@ -94,13 +90,20 @@ describe('Continuous Polling (ChangeDetector)', () => { config: { host: 'initial-host' }, createdAt: 0, }; - const newConfigData = { + const newConfigData1 = { configName: 'name', schemaId: commonDbPartialV1.$id, version: 1, config: { host: 'updated-host' }, createdAt: 1, }; + const newConfigData2 = { + configName: 'name', + schemaId: commonDbPartialV1.$id, + version: 1, + config: { host: 'updated-host-again' }, + createdAt: 2, + }; client .intercept({ path: '/capabilities', method: 'GET' }) @@ -109,9 +112,7 @@ describe('Continuous Polling (ChangeDetector)', () => { .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); - const exitMock = vi.spyOn(process, 'exit').mockImplementation(() => { - return undefined as never; - }); + const onChangeMock = vi.fn(); // Act await config({ @@ -121,79 +122,42 @@ describe('Continuous Polling (ChangeDetector)', () => { configServerUrl: URL, localConfigPath: './tests/config', pollIntervalMs: DEFAULT_POLL_INTERVAL, + terminatePod: false, + onChange: onChangeMock, }); - // Arrange (Next Poll) + // Assert (Initial State) + expect(onChangeMock).not.toHaveBeenCalled(); + + // Arrange (First config change) client .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET', headers: { 'if-none-match': 'etag-1' }, }) - .reply(StatusCodes.OK, newConfigData, { headers: { etag: 'etag-2' } }); + .reply(StatusCodes.OK, newConfigData1, { headers: { etag: 'etag-2' } }); - // Act (Wait for Poll) + // Act (Wait for First Poll) await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); - // Assert - expect(exitMock).toHaveBeenCalledWith(0); - }); - - it('should exit even if onChange throws an error', async () => { - // Arrange - const initialConfigData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, - config: { host: 'initial-host' }, - createdAt: 0, - }; - const newConfigData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, - config: { host: 'updated-host' }, - createdAt: 1, - }; - - client - .intercept({ path: '/capabilities', method: 'GET' }) - .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); - client - .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) - .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); - - const onChangeMock = vi.fn().mockRejectedValue(new Error('onChange error')); - const exitMock = vi.spyOn(process, 'exit').mockImplementation(() => { - return undefined as never; - }); - - // Act - await config({ - configName: 'name', - version: 1, - schema: commonDbPartialV1, - configServerUrl: URL, - localConfigPath: './tests/config', - pollIntervalMs: DEFAULT_POLL_INTERVAL, - onChange: onChangeMock, - }); + // Assert (First change triggered) + expect(onChangeMock).toHaveBeenCalledTimes(1); - // Arrange (Next Poll) + // Arrange (Second config change to verify polling resumed) client .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET', - headers: { 'if-none-match': 'etag-1' }, + headers: { 'if-none-match': 'etag-2' }, }) - .reply(StatusCodes.OK, newConfigData, { headers: { etag: 'etag-2' } }); + .reply(StatusCodes.OK, newConfigData2, { headers: { etag: 'etag-3' } }); - // Act (Wait for Poll) + // Act (Wait for Second Poll) await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); - // Assert - expect(onChangeMock).toHaveBeenCalledTimes(1); - expect(exitMock).toHaveBeenCalledWith(0); + // Assert (Second change triggered) + expect(onChangeMock).toHaveBeenCalledTimes(2); }); it('should not trigger onChange when polling returns 304 Not Modified', async () => { From ca9a5082762dc8699222fca7171cb9b85d492b67 Mon Sep 17 00:00:00 2001 From: netanelC Date: Thu, 18 Jun 2026 17:13:36 +0300 Subject: [PATCH 6/7] test: improve tests Co-authored-by: Copilot --- tests/config.spec.ts | 25 ++++--------- tests/mocks.ts | 13 +++++++ tests/rollout.spec.ts | 84 ++++++++----------------------------------- 3 files changed, 34 insertions(+), 88 deletions(-) create mode 100644 tests/mocks.ts diff --git a/tests/config.spec.ts b/tests/config.spec.ts index 13b7282..4e6447c 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -3,6 +3,7 @@ import { Interceptable, MockAgent, setGlobalDispatcher } from 'undici'; import { commonDbPartialV1, commonS3PartialV1 } from '@map-colonies/schemas'; import { StatusCodes } from 'http-status-codes'; import { config } from '../src/config'; +import { createMockConfigData } from './mocks'; const URL = 'http://localhost:8080'; describe('config', () => { @@ -17,15 +18,11 @@ describe('config', () => { }); it('should return the config with all the default values', async () => { - const configData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, + const configData = createMockConfigData({ config: { host: 'avi', }, - createdAt: 0, - }; + }); client .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) @@ -162,15 +159,11 @@ describe('config', () => { }); it('should return all the config parts', async () => { - const configData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, + const configData = createMockConfigData({ config: { host: 'avi', }, - createdAt: 0, - }; + }); client .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) @@ -227,15 +220,11 @@ describe('config', () => { }); it('should throw an error if the schema of the config is different from the schema of the server', async () => { - const configData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, + const configData = createMockConfigData({ config: { host: 'avi', }, - createdAt: 0, - }; + }); client .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonS3PartialV1.$id}`, method: 'GET' }) diff --git a/tests/mocks.ts b/tests/mocks.ts new file mode 100644 index 0000000..2572c45 --- /dev/null +++ b/tests/mocks.ts @@ -0,0 +1,13 @@ +import { commonDbPartialV1 } from '@map-colonies/schemas'; +import type { Config } from '../src/types'; + +export function createMockConfigData(overrides?: Partial): Partial { + return { + configName: 'name', + schemaId: commonDbPartialV1.$id, + version: 1, + config: { host: 'initial-host' }, + createdAt: 0, + ...overrides, + }; +} diff --git a/tests/rollout.spec.ts b/tests/rollout.spec.ts index 7bd6323..916ca10 100644 --- a/tests/rollout.spec.ts +++ b/tests/rollout.spec.ts @@ -3,12 +3,14 @@ import { Interceptable, MockAgent, setGlobalDispatcher } from 'undici'; import { commonDbPartialV1 } from '@map-colonies/schemas'; import { StatusCodes } from 'http-status-codes'; import { config } from '../src/config'; +import { createMockConfigData } from './mocks'; const URL = 'http://localhost:8080'; -const DEFAULT_POLL_INTERVAL = 10000; +const DEFAULT_POLL_INTERVAL = 30000; describe('Continuous Polling (ChangeDetector)', () => { let client: Interceptable; + const onChangeMock = vi.fn(); beforeEach(() => { vi.useFakeTimers(); @@ -21,24 +23,13 @@ describe('Continuous Polling (ChangeDetector)', () => { afterEach(() => { vi.restoreAllMocks(); + onChangeMock.mockReset(); }); - it('should trigger onChange and exit when polling returns a new config (200 OK)', async () => { + it('should trigger onChange when polling returns a new config (200 OK)', async () => { // Arrange - const initialConfigData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, - config: { host: 'initial-host' }, - createdAt: 0, - }; - const newConfigData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, - config: { host: 'updated-host' }, - createdAt: 1, - }; + const initialConfigData = createMockConfigData(); + const newConfigData = createMockConfigData({ config: { host: 'updated-host' }, createdAt: 1 }); client .intercept({ path: '/capabilities', method: 'GET' }) @@ -47,16 +38,12 @@ describe('Continuous Polling (ChangeDetector)', () => { .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); - const onChangeMock = vi.fn(); - // Act const configInstance = await config({ configName: 'name', version: 1, schema: commonDbPartialV1, - configServerUrl: URL, localConfigPath: './tests/config', - pollIntervalMs: DEFAULT_POLL_INTERVAL, onChange: onChangeMock, }); @@ -77,33 +64,14 @@ describe('Continuous Polling (ChangeDetector)', () => { await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); // Assert (Updated State) - expect(onChangeMock).toHaveBeenCalledTimes(1); - expect(onChangeMock).toHaveBeenCalledWith(); + expect(onChangeMock).toHaveBeenCalled(); }); - it('should trigger onChange and resume polling when polling returns a new config and terminatePod is false', async () => { + it('should resume polling when terminatePod is false', async () => { // Arrange - const initialConfigData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, - config: { host: 'initial-host' }, - createdAt: 0, - }; - const newConfigData1 = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, - config: { host: 'updated-host' }, - createdAt: 1, - }; - const newConfigData2 = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, - config: { host: 'updated-host-again' }, - createdAt: 2, - }; + const initialConfigData = createMockConfigData(); + const newConfigData1 = createMockConfigData({ config: { host: 'updated-host' }, createdAt: 1 }); + const newConfigData2 = createMockConfigData({ config: { host: 'updated-host-again' }, createdAt: 2 }); client .intercept({ path: '/capabilities', method: 'GET' }) @@ -112,16 +80,12 @@ describe('Continuous Polling (ChangeDetector)', () => { .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); - const onChangeMock = vi.fn(); - // Act await config({ configName: 'name', version: 1, schema: commonDbPartialV1, - configServerUrl: URL, localConfigPath: './tests/config', - pollIntervalMs: DEFAULT_POLL_INTERVAL, terminatePod: false, onChange: onChangeMock, }); @@ -162,13 +126,7 @@ describe('Continuous Polling (ChangeDetector)', () => { it('should not trigger onChange when polling returns 304 Not Modified', async () => { // Arrange - const initialConfigData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, - config: { host: 'initial-host' }, - createdAt: 0, - }; + const initialConfigData = createMockConfigData(); client .intercept({ path: '/capabilities', method: 'GET' }) @@ -177,16 +135,12 @@ describe('Continuous Polling (ChangeDetector)', () => { .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); - const onChangeMock = vi.fn(); - // Act const configInstance = await config({ configName: 'name', version: 1, schema: commonDbPartialV1, - configServerUrl: URL, localConfigPath: './tests/config', - pollIntervalMs: DEFAULT_POLL_INTERVAL, onChange: onChangeMock, }); @@ -209,13 +163,7 @@ describe('Continuous Polling (ChangeDetector)', () => { it('should stop polling when stop is called', async () => { // Arrange - const initialConfigData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, - config: { host: 'initial-host' }, - createdAt: 0, - }; + const initialConfigData = createMockConfigData(); client .intercept({ path: '/capabilities', method: 'GET' }) @@ -224,15 +172,11 @@ describe('Continuous Polling (ChangeDetector)', () => { .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); - const onChangeMock = vi.fn(); - const configInstance = await config({ configName: 'name', version: 1, schema: commonDbPartialV1, - configServerUrl: URL, localConfigPath: './tests/config', - pollIntervalMs: DEFAULT_POLL_INTERVAL, onChange: onChangeMock, }); From 142ef4bc651bd298903693c632d752d9a909adb6 Mon Sep 17 00:00:00 2001 From: netanelC Date: Thu, 18 Jun 2026 17:21:13 +0300 Subject: [PATCH 7/7] test: add use case Co-authored-by: Copilot --- tests/rollout.spec.ts | 49 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/rollout.spec.ts b/tests/rollout.spec.ts index 916ca10..c6a0276 100644 --- a/tests/rollout.spec.ts +++ b/tests/rollout.spec.ts @@ -67,7 +67,54 @@ describe('Continuous Polling (ChangeDetector)', () => { expect(onChangeMock).toHaveBeenCalled(); }); - it('should resume polling when terminatePod is false', async () => { + it('should stop polling when found new config and terminatePod is true', async () => { + // Arrange + const initialConfigData = createMockConfigData(); + const newConfigData = createMockConfigData({ config: { host: 'updated-host' }, createdAt: 1 }); + + client + .intercept({ path: '/capabilities', method: 'GET' }) + .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); + client + .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) + .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); + + // Act + await config({ + configName: 'name', + version: 1, + schema: commonDbPartialV1, + localConfigPath: './tests/config', + terminatePod: true, + onChange: onChangeMock, + }); + + // Assert (Initial State) + expect(onChangeMock).not.toHaveBeenCalled(); + + // Arrange (First config change) + client + .intercept({ + path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, + method: 'GET', + headers: { 'if-none-match': 'etag-1' }, + }) + .reply(StatusCodes.OK, newConfigData, { headers: { etag: 'etag-2' } }); + + // Act (Wait for First Poll) + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); + + // Assert (First change triggered) + expect(onChangeMock).toHaveBeenCalledTimes(1); + + // Act (Advance time to see if polling continues) + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); + + // Assert (Polling stopped, no further calls) + expect(onChangeMock).toHaveBeenCalledTimes(1); + }); + + it('should resume polling when found new config and terminatePod is false', async () => { // Arrange const initialConfigData = createMockConfigData(); const newConfigData1 = createMockConfigData({ config: { host: 'updated-host' }, createdAt: 1 });