@eliware/elera-lib 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/RELEASE_NOTES.md +46 -1
- package/package.json +2 -2
- package/src/client/create-db.mjs +11 -4
- package/src/client/environment.mjs +1 -1
- package/src/index.d.ts +4 -2
- package/src/pools/node-pool.mjs +14 -7
- package/src/pools/route-pool.mjs +6 -1
- package/src/routing/stream-client.mjs +7 -6
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The alternative SQL client for Eliware applications. It provides generic
|
|
4
4
|
primary/balanced MySQL or MariaDB routing without embedding Elera, HAProxy,
|
|
5
|
-
backup, or GitOps policy. It is a v0.1.
|
|
5
|
+
backup, or GitOps policy. It is a v0.1.3 alternative to `@eliware/mysql`; the
|
|
6
6
|
existing package is intentionally unchanged.
|
|
7
7
|
|
|
8
8
|
`primary` is the preferred connection path. `balanced` is an optional alternate
|
|
@@ -43,6 +43,15 @@ library does not know about supervisors, Elera, HAProxy, GitOps, backups, or
|
|
|
43
43
|
CLI commands. Applications provide those integrations through ordinary
|
|
44
44
|
configuration and callbacks.
|
|
45
45
|
|
|
46
|
+
When a route node is drained, the client immediately stops assigning new work
|
|
47
|
+
to that node while existing operations continue. The default drain window is
|
|
48
|
+
45 seconds and can be changed with `drainTimeoutMs`; remaining pool
|
|
49
|
+
connections are then force-closed. `client.drain(host)` returns `wait()` and
|
|
50
|
+
`forceClose()` operations, while `client.nodeStates()` exposes lifecycle and
|
|
51
|
+
active-operation state. Only conservative, single-statement reads are eligible
|
|
52
|
+
for automatic retry after a connection failure; uncertain writes are never
|
|
53
|
+
retried automatically.
|
|
54
|
+
|
|
46
55
|
The public client intentionally exposes SQL operations, health, routing,
|
|
47
56
|
lifecycle, and optional routing-event synchronization methods. REST and
|
|
48
57
|
WebSocket transports are adapters, not supervisor or CLI policy. Underlying
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,53 @@
|
|
|
1
1
|
# Release notes
|
|
2
2
|
|
|
3
|
+
## 0.1.3 — Client-side routing drain lifecycle
|
|
4
|
+
|
|
5
|
+
This patch adds generic client-side handling for supervisor-published routing
|
|
6
|
+
drains and topology resynchronization. It does not add Supervisor, CLI, Galera,
|
|
7
|
+
backup, or GitOps policy to the library.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Tracks active operations and acquired connections per route node.
|
|
12
|
+
- Exposes node lifecycle state and client drain status.
|
|
13
|
+
- Immediately excludes draining nodes from new work and force-closes remaining
|
|
14
|
+
pool connections after the configurable 45-second default drain window.
|
|
15
|
+
- Handles recovery events for primary and balanced routes.
|
|
16
|
+
- Retries eligible read operations only; uncertain writes are not retried.
|
|
17
|
+
- Rejects stale routing events and applies REST resync bundles through the
|
|
18
|
+
active routing handler.
|
|
19
|
+
- Adds WebSocket heartbeat scheduling and cleanup during reconnect and close.
|
|
20
|
+
|
|
21
|
+
### Validation
|
|
22
|
+
|
|
23
|
+
- Adds regression coverage for drain completion, forced cutoff, connection
|
|
24
|
+
accounting, safe retry behavior, stale events, REST resync, and heartbeats.
|
|
25
|
+
- Maintains 100% statements, branches, functions, and lines coverage with zero
|
|
26
|
+
lint warnings.
|
|
27
|
+
- Typecheck and diff validation pass.
|
|
28
|
+
|
|
29
|
+
## 0.1.2 — Unix-socket connection correction
|
|
30
|
+
|
|
31
|
+
This patch corrects the mysql2 option used for local MariaDB Unix-domain
|
|
32
|
+
socket connections.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Maps `MYSQL_SOCKET` to mysql2's `socketPath` option so local socket
|
|
37
|
+
connections reach the configured MariaDB socket instead of falling back to
|
|
38
|
+
TCP.
|
|
39
|
+
- Removes the obsolete `socket` option from node-pool driver options.
|
|
40
|
+
|
|
41
|
+
### Validation
|
|
42
|
+
|
|
43
|
+
- Adds regression coverage for socket-path mapping and option cleanup.
|
|
44
|
+
- Maintains 100% statements, branches, functions, and lines coverage with
|
|
45
|
+
zero lint warnings.
|
|
46
|
+
- Contract and syntax checks pass.
|
|
47
|
+
|
|
3
48
|
## 0.1.1 — Runtime integration readiness
|
|
4
49
|
|
|
5
|
-
This
|
|
50
|
+
This patch prepares the generic client for supervisor control-plane
|
|
6
51
|
connections and application-side routing changes. It does not add supervisor,
|
|
7
52
|
CLI, Galera, backup, or GitOps policy to the library.
|
|
8
53
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eliware/elera-lib",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Generic MySQL and MariaDB client with
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Generic MySQL and MariaDB client with resilient routing, client-side drains, and failover",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eliware",
|
|
7
7
|
"elera",
|
package/src/client/create-db.mjs
CHANGED
|
@@ -7,7 +7,12 @@ import { validateBundle, bundleExpired, bundleNeedsRefresh } from '../bundle.mjs
|
|
|
7
7
|
import { createRouteFactory } from './route-factory.mjs';
|
|
8
8
|
import { classifyQuery, routeFor } from '../routing.mjs';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
const olderVersion = (candidate, current) => {
|
|
11
|
+
if (candidate === undefined || current === undefined) return false;
|
|
12
|
+
return String(candidate) < String(current);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function createDb({ primary, balanced, bundle, credentialProvider, mysqlLib = mysql, log = defaultLog, routing = 'auto', identity, quarantineMs = 5000, drainTimeoutMs = 45000, now = () => Date.now() } = {}) {
|
|
11
16
|
if (!primary || typeof primary !== 'object') throw new TypeError('primary connection profile is required');
|
|
12
17
|
const credentials = await resolveCredentials(credentialProvider, credentialContext(primary, { identity }));
|
|
13
18
|
let primaryConfig = validateProfile({ ...primary, ...credentials }, 'primary');
|
|
@@ -23,12 +28,14 @@ export async function createDb({ primary, balanced, bundle, credentialProvider,
|
|
|
23
28
|
let balancedPool = balancedConfig || activeBundle?.routes?.balanced ? makeRoute('balanced', balancedConfig ?? primaryConfig) : null;
|
|
24
29
|
const choose = (sql, options = {}) => options.connection ?? (routeFor(sql, options.route ?? routing) === 'balanced' && balancedPool ? balancedPool : primaryPool);
|
|
25
30
|
const client = {
|
|
26
|
-
async query(sql, values, options) { const selected = choose(sql, options); try { return await selected.query(sql, values); } catch (error) { if (error.retryable && routeFor(sql,
|
|
31
|
+
async query(sql, values, options) { const selected = choose(sql, options); try { return await selected.query(sql, values); } catch (error) { const requestedRoute = options?.route ?? routing; if (error.retryable && balancedPool && routeFor(sql, requestedRoute) === 'balanced' && classifyQuery(sql) === 'balanced') return balancedPool.query(sql, values); throw error; } },
|
|
27
32
|
async execute(sql, values, options) { return choose(sql, options).execute(sql, values); },
|
|
28
33
|
async transaction(callback) { const node = primaryPool.choose(); const connection = await node.getConnection(); try { await connection.beginTransaction(); const tx = { query: (sql, values) => connection.query(sql, values), execute: (sql, values) => connection.execute(sql, values) }; const result = await callback(tx); await connection.commit(); return result; } catch (error) { await connection.rollback().catch(() => {}); throw asSqlError(error); } finally { connection.release(); } },
|
|
29
34
|
async health(route = 'primary') { const started = now(); const selected = route === 'balanced' && balancedPool ? balancedPool : primaryPool; const nodes = await selected.health(); return { ok: nodes.some((node) => node.ok), route: selected === balancedPool ? 'balanced' : 'primary', nodes, latencyMs: now() - started }; },
|
|
30
|
-
async refresh(nextBundle) { const candidate = validateBundle(nextBundle); if (bundleExpired(candidate, now())) throw new Error('routing bundle is expired'); const previous = [primaryPool, balancedPool]; const credentials = candidate.credentials ?? { username: primaryConfig.user, password: primaryConfig.password }; primaryConfig = validateProfile({ ...primaryConfig, host: candidate.routes.primary[0]?.host, port: candidate.routes.primary[0]?.port, user: credentials.username, password: credentials.password, database: candidate.database }, 'primary'); balancedConfig = candidate.routes.balanced?.[0] ? validateProfile({ ...primaryConfig, host: candidate.routes.balanced[0].host, port: candidate.routes.balanced[0].port }, 'balanced') : undefined; activeBundle = candidate; primaryPool = makeRoute('primary', primaryConfig); balancedPool = balancedConfig ? makeRoute('balanced', balancedConfig) : null; await Promise.all(previous.filter(Boolean).map((pool) => pool.close())); return { bundleVersion: activeBundle.bundleVersion ?? null, refreshRequired: bundleNeedsRefresh(activeBundle, now()) }; },
|
|
31
|
-
async attachRoutingStream(stream) { if (!stream?.connect) throw new TypeError('routing stream is required'); stream.setOnUpdate?.(async (event) => {
|
|
35
|
+
async refresh(nextBundle) { const candidate = validateBundle(nextBundle); if (bundleExpired(candidate, now())) throw new Error('routing bundle is expired'); if (olderVersion(candidate.bundleVersion, activeBundle?.bundleVersion)) return { bundleVersion: activeBundle?.bundleVersion, refreshRequired: bundleNeedsRefresh(activeBundle, now()) }; const previous = [primaryPool, balancedPool]; const credentials = candidate.credentials ?? { username: primaryConfig.user, password: primaryConfig.password }; primaryConfig = validateProfile({ ...primaryConfig, host: candidate.routes.primary[0]?.host, port: candidate.routes.primary[0]?.port, user: credentials.username, password: credentials.password, database: candidate.database }, 'primary'); balancedConfig = candidate.routes.balanced?.[0] ? validateProfile({ ...primaryConfig, host: candidate.routes.balanced[0].host, port: candidate.routes.balanced[0].port }, 'balanced') : undefined; activeBundle = candidate; primaryPool = makeRoute('primary', primaryConfig); balancedPool = balancedConfig ? makeRoute('balanced', balancedConfig) : null; await Promise.all(previous.filter(Boolean).map((pool) => pool.close())); return { bundleVersion: activeBundle.bundleVersion ?? null, refreshRequired: bundleNeedsRefresh(activeBundle, now()) }; },
|
|
36
|
+
async attachRoutingStream(stream) { if (!stream?.connect) throw new TypeError('routing stream is required'); stream.setOnUpdate?.(async (event) => { const update = event.type === 'routing.update' ? event : event.type === 'routing.resync' ? event.bundle : undefined; if (update?.routes?.primary?.length) await client.refresh({ ...activeBundle, database: update.database ?? activeBundle?.database ?? primaryConfig.database, credentials: update.credentials ?? activeBundle?.credentials, routes: update.routes, bundleVersion: update.bundleVersion ?? update.version ?? activeBundle?.bundleVersion, expiresAt: update.expiresAt ?? activeBundle?.expiresAt ?? new Date(now() + 60000).toISOString() }); if (event.type === 'routing.drain' || event.type === 'routing.recovery') for (const pool of [primaryPool, balancedPool].filter(Boolean)) (event.type === 'routing.drain' ? pool.drain : pool.recover)(event.node, drainTimeoutMs); }); await stream.connect(); return () => stream.close?.(); },
|
|
37
|
+
drain(host, timeoutMs = drainTimeoutMs) { const pools = [primaryPool, balancedPool].filter(Boolean); pools.forEach((pool) => pool.drain(host, timeoutMs)); return { host, timeoutMs, wait: () => Promise.all(pools.map((pool) => pool.waitForIdle(timeoutMs))), forceClose: () => Promise.all(pools.map((pool) => pool.forceClose(host))) }; },
|
|
38
|
+
nodeStates() { return [primaryPool, balancedPool].filter(Boolean).flatMap((pool) => pool.nodes.map((node) => ({ host: node.host, port: node.port, route: pool === primaryPool ? 'primary' : 'balanced', state: node.state, active: node.active, available: node.available }))); },
|
|
32
39
|
setNodeAvailability(route, host, available) { const pool = route === 'balanced' ? balancedPool : primaryPool; pool?.setAvailability(host, available); },
|
|
33
40
|
bundle: () => activeBundle,
|
|
34
41
|
async close() { await Promise.all([primaryPool.close(), balancedPool?.close()]); },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createDb } from './create-db.mjs';
|
|
2
2
|
|
|
3
3
|
export async function createDbFromEnvironment({ env = process.env, ...options } = {}) {
|
|
4
|
-
const primary = { host: env.MYSQL_PRIMARY_HOST ?? env.MYSQL_HOST ?? 'localhost', port: env.MYSQL_PRIMARY_PORT ?? env.MYSQL_PORT, user: env.MYSQL_USER, password: env.MYSQL_PASSWORD, database: env.MYSQL_DATABASE, options: {
|
|
4
|
+
const primary = { host: env.MYSQL_PRIMARY_HOST ?? env.MYSQL_HOST ?? 'localhost', port: env.MYSQL_PRIMARY_PORT ?? env.MYSQL_PORT, user: env.MYSQL_USER, password: env.MYSQL_PASSWORD, database: env.MYSQL_DATABASE, options: { socketPath: env.MYSQL_SOCKET, connectTimeout: env.MYSQL_CONNECT_TIMEOUT, acquireTimeout: env.MYSQL_ACQUIRE_TIMEOUT, connectionLimit: env.MYSQL_CONNECTION_LIMIT, queueLimit: env.MYSQL_QUEUE_LIMIT, ssl: env.MYSQL_SSL } };
|
|
5
5
|
const balanced = env.MYSQL_BALANCED_PORT ? { host: env.MYSQL_BALANCED_HOST ?? primary.host, port: env.MYSQL_BALANCED_PORT } : undefined;
|
|
6
6
|
return createDb({ ...options, primary, balanced });
|
|
7
7
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface DbClient {
|
|
|
36
36
|
classify(sql: string): 'primary' | 'balanced';
|
|
37
37
|
attachRoutingStream(stream: RoutingStream): Promise<() => void>;
|
|
38
38
|
setNodeAvailability(route: 'primary' | 'balanced', host: string, available: boolean): void;
|
|
39
|
+
drain(host: string, timeoutMs?: number): { host: string; timeoutMs: number; wait(): Promise<unknown[]>; forceClose(): Promise<unknown[]> };
|
|
40
|
+
nodeStates(): Array<{ host: string; port: number; route: 'primary' | 'balanced'; state: 'ready' | 'draining' | 'unavailable' | 'recovering'; active: number; available: boolean }>;
|
|
39
41
|
config: { primary: ConnectionProfile; balanced?: ConnectionProfile };
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -46,7 +48,7 @@ export interface RoutingStream {
|
|
|
46
48
|
state(): { connected: boolean; mode: 'websocket' | 'rest' | 'disconnected'; expectedVersion: number };
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
export function createDb(options: { primary: ConnectionProfile; balanced?: Partial<ConnectionProfile>; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; quarantineMs?: number; now?: () => number }): Promise<DbClient>;
|
|
51
|
+
export function createDb(options: { primary: ConnectionProfile; balanced?: Partial<ConnectionProfile>; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; quarantineMs?: number; drainTimeoutMs?: number; now?: () => number }): Promise<DbClient>;
|
|
50
52
|
export function createDbFromEnvironment(options?: { env?: Record<string, string | undefined>; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string }): Promise<DbClient>;
|
|
51
53
|
export function classifyQuery(sql: unknown): 'primary' | 'balanced';
|
|
52
54
|
export function routeFor(sql: unknown, requested?: 'auto' | 'primary' | 'balanced'): 'primary' | 'balanced';
|
|
@@ -61,7 +63,7 @@ export function bundleNeedsRefresh(bundle: RoutingBundle, now?: number): boolean
|
|
|
61
63
|
export function createAdminSql(options: { query: QueryFunction }): { transaction<T>(work: (context: { query: QueryFunction }) => Promise<T>): Promise<T>; migration(statements?: string[]): Promise<unknown> };
|
|
62
64
|
export function createMigrationRunner(options: { query: QueryFunction; migrations?: Array<{ version: number; name: string; statements: string[] }> }): { status(): Promise<{ applied: unknown[] }>; migrate(): Promise<unknown> };
|
|
63
65
|
export function selectRouteNodes(options: { bundle: RoutingBundle; route?: 'primary' | 'balanced'; now?: number }): RoutingNode[];
|
|
64
|
-
export function createRoutingStream(options: { endpoint: string; token?: string; application?: string; fetchBundle: (application: string) => Promise<RoutingBundle>; onUpdate?: (event: unknown) => void; onError?: (error: unknown) => void; reconnectMs?: number; maxReconnectMs?: number }): RoutingStream;
|
|
66
|
+
export function createRoutingStream(options: { endpoint: string; token?: string; application?: string; fetchBundle: (application: string) => Promise<RoutingBundle>; onUpdate?: (event: unknown) => void; onError?: (error: unknown) => void; reconnectMs?: number; maxReconnectMs?: number; heartbeatMs?: number }): RoutingStream;
|
|
65
67
|
export function createQuiesceController(options?: { close?: () => Promise<void>; onChange?: (state: string) => void }): { state(): { state: string; active: number }; enter(): () => void; begin(): Promise<void>; end(): Promise<void>; close(): Promise<void> };
|
|
66
68
|
export function createSqlVerifier(options: { query: QueryFunction }): { connectivity(): Promise<{ verified: boolean }>; schema(database: string): Promise<{ database: string; verified: boolean }>; account(user: string, host?: string): Promise<{ user: string; host: string; verified: boolean; grants: string[] }>; all(options?: { database?: string; user?: string; host?: string }): Promise<unknown> };
|
|
67
69
|
export function createMaterializer(options?: Record<string, unknown>): unknown;
|
package/src/pools/node-pool.mjs
CHANGED
|
@@ -2,15 +2,22 @@ import { asSqlError } from '../errors.mjs';
|
|
|
2
2
|
const connectionFailure = (error) => asSqlError(error).retryable;
|
|
3
3
|
export function createNodePool({ profile, mysqlLib, log, now = () => Date.now(), quarantineMs = 5000 }) {
|
|
4
4
|
const { acquireTimeout: _acquireTimeout, ...driverOptions } = profile.options ?? {};
|
|
5
|
-
if (driverOptions.
|
|
5
|
+
if (driverOptions.socketPath === undefined) delete driverOptions.socketPath;
|
|
6
6
|
const pool = mysqlLib.createPool({ host: profile.host, port: profile.port, user: profile.user, password: profile.password, database: profile.database, waitForConnections: true, ...driverOptions });
|
|
7
7
|
const sessionStatements = profile.options?.sessionStatements ?? [];
|
|
8
8
|
const withConnection = async (operation, sql, values) => { const connection = await pool.getConnection(); try { for (const statement of sessionStatements) await connection.query(statement); return operation(connection, sql, values); } finally { connection.release(); } };
|
|
9
|
-
let failures = 0; let unavailableUntil = 0; let forcedUnavailable = false;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
let failures = 0; let unavailableUntil = 0; let forcedUnavailable = false; let lifecycle = 'ready'; let active = 0; let idleResolve; let drainTimer;
|
|
10
|
+
const begin = () => { active += 1; };
|
|
11
|
+
const end = () => { active = Math.max(0, active - 1); if (!active) idleResolve?.(); };
|
|
12
|
+
const run = async (work) => { begin(); try { return await work(); } finally { end(); } };
|
|
13
|
+
const drain = (timeoutMs = 45000) => { lifecycle = 'draining'; forcedUnavailable = true; unavailableUntil = Number.POSITIVE_INFINITY; clearTimeout(drainTimer); drainTimer = setTimeout(() => { void forceClose(); }, timeoutMs); drainTimer.unref?.(); return { state: lifecycle, active }; };
|
|
14
|
+
const waitForIdle = async (timeoutMs = 45000) => { if (!active) return true; await Promise.race([new Promise((resolve) => { idleResolve = resolve; }), new Promise((resolve) => setTimeout(resolve, timeoutMs))]); idleResolve = undefined; return active === 0; };
|
|
15
|
+
const forceClose = async () => { lifecycle = 'unavailable'; await pool.end(); };
|
|
16
|
+
const recover = () => { clearTimeout(drainTimer); drainTimer = undefined; lifecycle = 'recovering'; forcedUnavailable = false; unavailableUntil = 0; lifecycle = 'ready'; return lifecycle; };
|
|
17
|
+
return { host: profile.host, port: profile.port, weight: Number(profile.weight ?? 100), get available() { return lifecycle === 'ready' && !forcedUnavailable && now() >= unavailableUntil; }, set available(value) { if (value) recover(); else drain(); }, get failures() { return failures; }, get state() { return lifecycle; }, get active() { return active; }, drain, recover, waitForIdle, forceClose,
|
|
18
|
+
async query(sql, values) { if (!this.available) throw new Error(`SQL node ${profile.host} is unavailable`); try { const result = await run(() => sessionStatements.length ? withConnection((connection, statement, params) => connection.query(statement, params), sql, values) : pool.query(sql, values)); failures = 0; return result; } catch (error) { if (connectionFailure(error)) { failures += 1; unavailableUntil = now() + quarantineMs; log?.warn?.('SQL node quarantined', { host: profile.host, port: profile.port, error: error.message }); } throw asSqlError(error); } },
|
|
19
|
+
async execute(sql, values) { if (!this.available) throw new Error(`SQL node ${profile.host} is unavailable`); try { const result = await run(() => sessionStatements.length ? withConnection((connection, statement, params) => connection.execute(statement, params), sql, values) : pool.execute(sql, values)); failures = 0; return result; } catch (error) { if (connectionFailure(error)) { failures += 1; unavailableUntil = now() + quarantineMs; } throw asSqlError(error); } },
|
|
20
|
+
async getConnection() { if (!this.available) throw new Error(`SQL node ${profile.host} is unavailable`); try { const connection = await pool.getConnection(); begin(); const release = connection.release.bind(connection); let released = false; connection.release = () => { if (released) return; released = true; end(); release(); }; return connection; } catch (error) { if (connectionFailure(error)) unavailableUntil = now() + quarantineMs; throw asSqlError(error); } },
|
|
14
21
|
async health() { try { await pool.query('SELECT 1'); failures = 0; unavailableUntil = 0; return { ok: true, host: profile.host, port: profile.port }; } catch (error) { if (connectionFailure(error)) { failures += 1; unavailableUntil = now() + quarantineMs; } throw asSqlError(error); } },
|
|
15
|
-
async close() { await pool.end(); } };
|
|
22
|
+
async close() { clearTimeout(drainTimer); lifecycle = 'unavailable'; await pool.end(); } };
|
|
16
23
|
}
|
package/src/pools/route-pool.mjs
CHANGED
|
@@ -21,6 +21,11 @@ export function createRoutePool(nodes) {
|
|
|
21
21
|
const setAvailability = (host, available) => {
|
|
22
22
|
for (const node of nodes) if (node.host === host) node.available = available;
|
|
23
23
|
};
|
|
24
|
+
const lifecycle = (host, state) => nodes.filter((node) => node.host === host).map((node) => state === 'draining' ? node.drain() : node.recover());
|
|
25
|
+
const drain = (host, timeoutMs) => { for (const node of nodes.filter((value) => value.host === host)) node.drain?.(timeoutMs); return lifecycle(host, 'draining'); };
|
|
26
|
+
const recover = (host) => lifecycle(host, 'recovering');
|
|
27
|
+
const waitForIdle = (timeoutMs) => Promise.all(nodes.map((node) => node.waitForIdle?.(timeoutMs) ?? true));
|
|
28
|
+
const forceClose = (host) => Promise.all(nodes.filter((node) => !host || node.host === host).map((node) => node.forceClose?.() ?? node.close()));
|
|
24
29
|
const query = (sql, values) => choose().query(sql, values);
|
|
25
30
|
const execute = (sql, values) => choose().execute(sql, values);
|
|
26
31
|
const health = async () => {
|
|
@@ -35,5 +40,5 @@ export function createRoutePool(nodes) {
|
|
|
35
40
|
return results;
|
|
36
41
|
};
|
|
37
42
|
const close = async () => Promise.all(nodes.map((node) => node.close()));
|
|
38
|
-
return { nodes, choose, setAvailability, query, execute, health, close };
|
|
43
|
+
return { nodes, choose, setAvailability, drain, recover, waitForIdle, forceClose, query, execute, health, close };
|
|
39
44
|
}
|
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
import { log as defaultLog } from '@eliware/common';
|
|
2
2
|
|
|
3
|
-
export function createRoutingStream({ endpoint, token, application = 'default', fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, now = () => Date.now() } = {}) {
|
|
3
|
+
export function createRoutingStream({ endpoint, token, application = 'default', fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, heartbeatMs = 45000, now = () => Date.now() } = {}) {
|
|
4
4
|
if (!endpoint || typeof fetchBundle !== 'function') throw new TypeError('endpoint and fetchBundle are required');
|
|
5
|
-
let socket; let closed = false; let timer; let expectedVersion = 0; let delay = reconnectMs; let updateHandler = onUpdate; let mode = 'disconnected';
|
|
5
|
+
let socket; let closed = false; let timer; let heartbeat; let expectedVersion = 0; let delay = reconnectMs; let updateHandler = onUpdate; let mode = 'disconnected';
|
|
6
6
|
const log = arguments[0]?.log ?? defaultLog;
|
|
7
7
|
const streamUrl = () => `${endpoint.replace(/^http/i, 'ws').replace(/\/$/, '')}/api/v1/routing/stream?application=${encodeURIComponent(application)}&token=${encodeURIComponent(token ?? '')}`;
|
|
8
|
-
async function fallback() { try { const bundle = await fetchBundle(application); if (closed) return; mode = 'rest';
|
|
8
|
+
async function fallback() { try { const bundle = await fetchBundle(application); if (closed) return; mode = 'rest'; updateHandler?.({ type: 'routing.resync', version: expectedVersion, bundle, receivedAt: now() }); } catch (error) { if (closed) return; mode = 'disconnected'; onError?.(error); log.warn?.('Routing REST fallback failed', { error }); } }
|
|
9
9
|
function schedule() { if (closed || timer) return; timer = setTimeout(() => { timer = undefined; void connect(); }, delay); delay = Math.min(maxReconnectMs, delay * 2); }
|
|
10
10
|
async function connect() {
|
|
11
11
|
if (closed || typeof WebSocketImpl !== 'function') { await fallback(); schedule(); return; }
|
|
12
12
|
try {
|
|
13
13
|
socket = new WebSocketImpl(streamUrl());
|
|
14
|
-
socket.onopen = () => { mode = 'websocket'; delay = reconnectMs; };
|
|
14
|
+
socket.onopen = () => { mode = 'websocket'; delay = reconnectMs; heartbeat = setInterval(() => socket?.send?.(JSON.stringify({ type: 'heartbeat', sentAt: now() })), heartbeatMs); };
|
|
15
15
|
socket.onmessage = async ({ data }) => {
|
|
16
16
|
try {
|
|
17
17
|
const event = JSON.parse(data); const version = Number(event.version ?? 0);
|
|
18
|
+
if (version && expectedVersion && version <= expectedVersion) return;
|
|
18
19
|
if (expectedVersion && version > expectedVersion + 1) await fallback();
|
|
19
20
|
expectedVersion = Math.max(expectedVersion, version); updateHandler?.(event);
|
|
20
21
|
} catch (error) { onError?.(error); }
|
|
21
22
|
};
|
|
22
23
|
socket.onerror = (error) => { onError?.(error); };
|
|
23
|
-
socket.onclose = () => { socket = undefined; mode = 'disconnected'; if (!closed) { void fallback(); schedule(); } };
|
|
24
|
+
socket.onclose = () => { clearInterval(heartbeat); heartbeat = undefined; socket = undefined; mode = 'disconnected'; if (!closed) { void fallback(); schedule(); } };
|
|
24
25
|
} catch (error) { mode = 'disconnected'; onError?.(error); await fallback(); schedule(); }
|
|
25
26
|
}
|
|
26
|
-
return { connect, setOnUpdate: (handler) => { updateHandler = handler; }, close: () => { closed = true; mode = 'disconnected'; clearTimeout(timer); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, mode, expectedVersion }) };
|
|
27
|
+
return { connect, setOnUpdate: (handler) => { updateHandler = handler; }, close: () => { closed = true; mode = 'disconnected'; clearTimeout(timer); clearInterval(heartbeat); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, mode, expectedVersion }) };
|
|
27
28
|
}
|