@eliware/elera-lib 0.1.3 → 0.1.5
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 +25 -13
- package/RELEASE_NOTES.md +60 -0
- package/examples/README.md +13 -0
- package/examples/basic-client.mjs +14 -0
- package/package.json +5 -2
- package/src/bundle.mjs +6 -2
- package/src/client/bundle-profiles.mjs +7 -0
- package/src/client/create-db.mjs +14 -11
- package/src/client/route-factory.mjs +2 -6
- package/src/client/telemetry-wrapper.mjs +15 -0
- package/src/index.d.ts +18 -4
- package/src/index.mjs +4 -0
- package/src/lifecycle/drain-policy.mjs +7 -0
- package/src/lifecycle/materializer.mjs +2 -2
- package/src/pools/route-pool.mjs +2 -1
- package/src/routing/assignment.mjs +9 -0
- package/src/routing/bundle-version.mjs +11 -0
- package/src/routing/node-validation.mjs +14 -0
- package/src/routing/stream-client.mjs +8 -5
- package/src/telemetry.mjs +8 -0
package/README.md
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
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.
|
|
6
|
-
existing package is intentionally unchanged.
|
|
5
|
+
backup, or GitOps policy. It is a v0.1.5 alternative to `@eliware/mysql`; the
|
|
6
|
+
existing package is intentionally unchanged. The current package version is
|
|
7
|
+
0.1.5.
|
|
7
8
|
|
|
8
9
|
`primary` is the preferred connection path. `balanced` is an optional alternate
|
|
9
10
|
path. Both may accept writes; automatic routing sends only conservative,
|
|
@@ -26,12 +27,17 @@ accounts. Pool settings may be supplied with `MYSQL_CONNECT_TIMEOUT`,
|
|
|
26
27
|
`MYSQL_SSL`. Configure primary and balanced routes explicitly; applications
|
|
27
28
|
should not rely on ambiguous single-endpoint aliases.
|
|
28
29
|
|
|
30
|
+
See examples/basic-client.mjs for a complete consumer example using only the
|
|
31
|
+
public package API. Its usage notes are in examples/README.md.
|
|
32
|
+
|
|
29
33
|
Routing bundles passed to `createDbFromBundle` use the normalized shape
|
|
30
34
|
`routes.primary` and `routes.balanced`, each containing ordered `{ host, port,
|
|
31
|
-
weight }` nodes. A bundle also
|
|
32
|
-
`
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
weight }` nodes. A bundle may also carry an explicit `writer`, ordered
|
|
36
|
+
`failover`, and `readers` assignment. The bundle carries `database`,
|
|
37
|
+
`identity`, optional `credentials`, and `expiresAt`; `validateBundle` rejects
|
|
38
|
+
expired, malformed, duplicated, or conflicting route data. The checked-in
|
|
39
|
+
contract fixture documents the supervisor-facing wire representation
|
|
40
|
+
separately.
|
|
35
41
|
|
|
36
42
|
The implementation accepts optional routing bundles and injected credential
|
|
37
43
|
providers, maintains bounded pools per route, supports ordered writer/reader
|
|
@@ -44,13 +50,13 @@ CLI commands. Applications provide those integrations through ordinary
|
|
|
44
50
|
configuration and callbacks.
|
|
45
51
|
|
|
46
52
|
When a route node is drained, the client immediately stops assigning new work
|
|
47
|
-
to that node while existing operations continue. The
|
|
48
|
-
45 seconds and
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
to that node while existing operations continue. The drain window defaults to
|
|
54
|
+
45 seconds and is capped at 45 seconds; remaining pool connections are then
|
|
55
|
+
force-closed. `client.drain(host)` returns `wait()` and `forceClose()`
|
|
56
|
+
operations, while `client.nodeStates()` exposes lifecycle and active-operation
|
|
57
|
+
state. Only conservative, single-statement reads are eligible for automatic
|
|
58
|
+
retry after a connection failure; uncertain writes are never retried
|
|
59
|
+
automatically.
|
|
54
60
|
|
|
55
61
|
The public client intentionally exposes SQL operations, health, routing,
|
|
56
62
|
lifecycle, and optional routing-event synchronization methods. REST and
|
|
@@ -63,6 +69,12 @@ schema, account, and grant checks. Neither API transports or orchestrates dump
|
|
|
63
69
|
contents. The stream reports `websocket`, `rest`, or `disconnected` mode so
|
|
64
70
|
callers can observe transport health without implementing transport policy.
|
|
65
71
|
|
|
72
|
+
Applications may opt into generic in-memory telemetry with
|
|
73
|
+
`createDb({ ..., telemetry: true })`. Query counts, failures, retries,
|
|
74
|
+
in-flight work, and latency are exposed through `client.telemetry` and sent
|
|
75
|
+
over an attached routing stream once per second. Telemetry is observational
|
|
76
|
+
only; it does not carry SQL or credentials.
|
|
77
|
+
|
|
66
78
|
`createMaterializer` supports bounded plaintext use for a caller-provided
|
|
67
79
|
operation. It creates a mode-restricted temporary file and removes its entire
|
|
68
80
|
temporary directory in a `finally` block; this limits lifetime and cleanup but
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Release notes
|
|
2
2
|
|
|
3
|
+
## 0.1.5 — Telemetry and convention alignment
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Adds opt-in generic in-memory client telemetry for query counts, failures,
|
|
8
|
+
retries, in-flight work, and latency.
|
|
9
|
+
- Sends telemetry over an attached routing stream once per second without
|
|
10
|
+
sending SQL text or credentials.
|
|
11
|
+
- Adds public TypeScript declarations and smoke coverage for the telemetry and
|
|
12
|
+
public client surface.
|
|
13
|
+
- Uses Snowflake identifiers for non-security temporary materialization paths.
|
|
14
|
+
|
|
15
|
+
### Refactored
|
|
16
|
+
|
|
17
|
+
- Extracts telemetry timing into a focused client module.
|
|
18
|
+
- Reorganizes `create-db` tests under the mirrored `tests/client/create-db/`
|
|
19
|
+
hierarchy while retaining a small cross-cutting contract test.
|
|
20
|
+
|
|
21
|
+
### Validation
|
|
22
|
+
|
|
23
|
+
- Tests pass with 100% statements, branches, functions, and lines coverage.
|
|
24
|
+
- TypeScript typecheck passes with zero lint warnings.
|
|
25
|
+
|
|
26
|
+
## 0.1.4 — Explicit writer and failover routing
|
|
27
|
+
|
|
28
|
+
This release strengthens generic client-side routing for supervisor-provided
|
|
29
|
+
bundles. Applications still use the public library API; supervisor and CLI
|
|
30
|
+
policy remain outside the package.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- Supports explicit `writer`, ordered `failover`, and `readers` assignments in
|
|
35
|
+
routing bundles.
|
|
36
|
+
- Replaces active writer and reader pools immediately when a valid routing
|
|
37
|
+
update arrives through WebSocket or REST resynchronization.
|
|
38
|
+
- Keeps application clients independent so updating one application's bundle
|
|
39
|
+
does not change another client's assignment.
|
|
40
|
+
- Selects the writer first and fails over in the supplied order when a node is
|
|
41
|
+
drained or explicitly unavailable.
|
|
42
|
+
- Completes in-flight work during drain while excluding the node from new work.
|
|
43
|
+
- Enforces a 45-second maximum client-side drain window.
|
|
44
|
+
|
|
45
|
+
### Fixed and hardened
|
|
46
|
+
|
|
47
|
+
- Compares numeric and string bundle versions numerically and rejects stale
|
|
48
|
+
updates, including versions such as `v10` versus `v9`.
|
|
49
|
+
- Validates routing hosts, ports, weights, duplicate nodes, and writer/failover
|
|
50
|
+
overlap before pool creation.
|
|
51
|
+
- Preserves explicit writer, failover, and reader assignments during stream
|
|
52
|
+
refreshes.
|
|
53
|
+
|
|
54
|
+
### Validation
|
|
55
|
+
|
|
56
|
+
- Adds integration-style coverage for pool replacement, per-client assignment
|
|
57
|
+
isolation, writer failover, in-flight drain behavior, version ordering,
|
|
58
|
+
WebSocket reconnect, and REST fallback.
|
|
59
|
+
- Maintains 100% statements, branches, functions, and lines coverage with zero
|
|
60
|
+
lint warnings.
|
|
61
|
+
- Diff validation passes.
|
|
62
|
+
|
|
3
63
|
## 0.1.3 — Client-side routing drain lifecycle
|
|
4
64
|
|
|
5
65
|
This patch adds generic client-side handling for supervisor-published routing
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Elera library example
|
|
2
|
+
|
|
3
|
+
basic-client.mjs is a minimal consumer application. It imports only the
|
|
4
|
+
public @eliware/elera-lib package API, opens a bounded SQL client from
|
|
5
|
+
environment configuration, performs a health check and query, and closes the
|
|
6
|
+
client in a finally block.
|
|
7
|
+
|
|
8
|
+
Run it from a published-package consumer project with:
|
|
9
|
+
|
|
10
|
+
MYSQL_PRIMARY_HOST=127.0.0.1 MYSQL_USER=app MYSQL_PASSWORD=secret MYSQL_DATABASE=app node examples/basic-client.mjs
|
|
11
|
+
|
|
12
|
+
The example is intentionally infrastructure-neutral. It does not contain
|
|
13
|
+
Docker, Kubernetes, Supervisor, CLI, Galera, or test-lab setup.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createDbFromEnvironment } from '@eliware/elera-lib';
|
|
2
|
+
|
|
3
|
+
// Set MYSQL_PRIMARY_HOST, MYSQL_USER, MYSQL_PASSWORD, and MYSQL_DATABASE
|
|
4
|
+
// before running this example. The same public API works with a routing bundle.
|
|
5
|
+
const db = await createDbFromEnvironment();
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const health = await db.health('primary');
|
|
9
|
+
if (!health.ok) throw new Error('primary SQL route is not healthy');
|
|
10
|
+
const [rows] = await db.query('SELECT 1 AS healthy');
|
|
11
|
+
console.log(JSON.stringify({ health, rows }));
|
|
12
|
+
} finally {
|
|
13
|
+
await db.close();
|
|
14
|
+
}
|
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 resilient routing, client-side drains, and failover",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "Generic MySQL and MariaDB client with resilient routing, telemetry, client-side drains, and failover",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eliware",
|
|
7
7
|
"elera",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"database",
|
|
12
12
|
"routing",
|
|
13
13
|
"failover",
|
|
14
|
+
"telemetry",
|
|
14
15
|
"websocket",
|
|
15
16
|
"connection-pool"
|
|
16
17
|
],
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
},
|
|
36
37
|
"files": [
|
|
37
38
|
"src",
|
|
39
|
+
"examples",
|
|
38
40
|
"README.md",
|
|
39
41
|
"RELEASE_NOTES.md",
|
|
40
42
|
"LICENSE",
|
|
@@ -52,6 +54,7 @@
|
|
|
52
54
|
},
|
|
53
55
|
"dependencies": {
|
|
54
56
|
"@eliware/common": "^2.0.0",
|
|
57
|
+
"@eliware/snowflake": "^2.0.0",
|
|
55
58
|
"mysql2": "^3.24.2"
|
|
56
59
|
},
|
|
57
60
|
"devDependencies": {
|
package/src/bundle.mjs
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
import { validateRoutingNode, validateRoutingNodes } from './routing/node-validation.mjs';
|
|
1
2
|
const routes = ['primary', 'balanced'];
|
|
2
3
|
|
|
3
4
|
export function validateBundle(bundle) {
|
|
4
5
|
if (!bundle || typeof bundle !== 'object') throw new TypeError('routing bundle is required');
|
|
5
6
|
if (!bundle.expiresAt || Number.isNaN(Date.parse(bundle.expiresAt))) throw new TypeError('routing bundle expiresAt is required');
|
|
7
|
+
const writer = bundle.writer !== undefined ? validateRoutingNode(bundle.writer, 'routing bundle writer') : undefined;
|
|
8
|
+
const failover = bundle.failover !== undefined ? validateRoutingNodes(bundle.failover, 'routing bundle failover') : [];
|
|
9
|
+
if (writer && failover.some((node) => node.host === writer.host && node.port === writer.port)) throw new TypeError('routing bundle failover duplicates writer');
|
|
10
|
+
if (bundle.readers !== undefined) validateRoutingNodes(bundle.readers, 'routing bundle readers');
|
|
6
11
|
for (const route of routes) {
|
|
7
12
|
if (bundle.routes?.[route] !== undefined && !Array.isArray(bundle.routes[route])) throw new TypeError(`bundle.routes.${route} must be an array`);
|
|
8
13
|
for (const node of bundle.routes?.[route] ?? []) {
|
|
9
|
-
|
|
10
|
-
if (node.weight !== undefined && (!Number.isFinite(Number(node.weight)) || Number(node.weight) < 0)) throw new TypeError(`invalid ${route} bundle weight`);
|
|
14
|
+
validateRoutingNode(node, `routing bundle ${route} node`);
|
|
11
15
|
}
|
|
12
16
|
}
|
|
13
17
|
return bundle;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { validateRoutingNode } from '../routing/node-validation.mjs';
|
|
2
|
+
|
|
3
|
+
export function bundleProfiles(bundle, route, base) {
|
|
4
|
+
if (route === 'primary' && bundle.writer?.host) return [bundle.writer, ...(bundle.failover ?? [])].map((node, index) => { const valid = validateRoutingNode(node, `primary assignment[${index}]`); return { ...base, host: valid.host, port: valid.port, weight: valid.weight }; });
|
|
5
|
+
const nodes = route === 'balanced' && bundle.readers?.length ? bundle.readers : bundle.routes?.[route] ?? [];
|
|
6
|
+
return nodes.map((node) => { const valid = validateRoutingNode(node, `${route} route node`); return { ...base, host: valid.host, port: valid.port, weight: valid.weight }; });
|
|
7
|
+
}
|
package/src/client/create-db.mjs
CHANGED
|
@@ -6,13 +6,14 @@ import { resolveCredentials, credentialContext } from '../credential-provider.mj
|
|
|
6
6
|
import { validateBundle, bundleExpired, bundleNeedsRefresh } from '../bundle.mjs';
|
|
7
7
|
import { createRouteFactory } from './route-factory.mjs';
|
|
8
8
|
import { classifyQuery, routeFor } from '../routing.mjs';
|
|
9
|
+
import { compareBundleVersions } from '../routing/bundle-version.mjs';
|
|
10
|
+
import { clientDrainTimeout } from '../lifecycle/drain-policy.mjs';
|
|
11
|
+
import { createTelemetry } from '../telemetry.mjs';
|
|
12
|
+
import { createTimedOperation } from './telemetry-wrapper.mjs';
|
|
9
13
|
|
|
10
|
-
const olderVersion = (candidate, current) =>
|
|
11
|
-
if (candidate === undefined || current === undefined) return false;
|
|
12
|
-
return String(candidate) < String(current);
|
|
13
|
-
};
|
|
14
|
+
const olderVersion = (candidate, current) => compareBundleVersions(candidate, current) < 0;
|
|
14
15
|
|
|
15
|
-
export async function createDb({ primary, balanced, bundle, credentialProvider, mysqlLib = mysql, log = defaultLog, routing = 'auto', identity, quarantineMs = 5000, drainTimeoutMs = 45000, now = () => Date.now() } = {}) {
|
|
16
|
+
export async function createDb({ primary, balanced, bundle, credentialProvider, mysqlLib = mysql, log = defaultLog, routing = 'auto', identity, quarantineMs = 5000, drainTimeoutMs = 45000, now = () => Date.now(), telemetry } = {}) {
|
|
16
17
|
if (!primary || typeof primary !== 'object') throw new TypeError('primary connection profile is required');
|
|
17
18
|
const credentials = await resolveCredentials(credentialProvider, credentialContext(primary, { identity }));
|
|
18
19
|
let primaryConfig = validateProfile({ ...primary, ...credentials }, 'primary');
|
|
@@ -27,20 +28,22 @@ export async function createDb({ primary, balanced, bundle, credentialProvider,
|
|
|
27
28
|
let primaryPool = makeRoute('primary', primaryConfig);
|
|
28
29
|
let balancedPool = balancedConfig || activeBundle?.routes?.balanced ? makeRoute('balanced', balancedConfig ?? primaryConfig) : null;
|
|
29
30
|
const choose = (sql, options = {}) => options.connection ?? (routeFor(sql, options.route ?? routing) === 'balanced' && balancedPool ? balancedPool : primaryPool);
|
|
31
|
+
const metrics = telemetry === true ? createTelemetry({ application: bundle?.application ?? identity ?? 'default', now }) : telemetry;
|
|
32
|
+
const timed = createTimedOperation({ metrics, now });
|
|
30
33
|
const client = {
|
|
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; } },
|
|
34
|
+
async query(sql, values, options) { return timed(async () => { 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') { metrics?.record?.({ retry: true }); return balancedPool.query(sql, values); } throw error; } }); },
|
|
32
35
|
async execute(sql, values, options) { return choose(sql, options).execute(sql, values); },
|
|
33
36
|
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(); } },
|
|
34
37
|
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 }; },
|
|
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 };
|
|
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
|
|
37
|
-
drain(host, timeoutMs = drainTimeoutMs) { const pools = [primaryPool, balancedPool].filter(Boolean); pools.forEach((pool) => pool.drain(host,
|
|
38
|
+
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 }; const writer = candidate.writer ?? candidate.routes.primary?.[0]; const reader = candidate.readers?.[0] ?? candidate.routes.balanced?.[0]; primaryConfig = validateProfile({ ...primaryConfig, host: writer?.host, port: writer?.port, user: credentials.username, password: credentials.password, database: candidate.database }, 'primary'); balancedConfig = reader ? validateProfile({ ...primaryConfig, host: reader.host, port: reader.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()) }; },
|
|
39
|
+
async attachRoutingStream(stream) { if (!stream?.connect) throw new TypeError('routing stream is required'); metrics?.start?.(stream); stream.setOnUpdate?.(async (event) => { const update = event.type === 'routing.update' ? event : event.type === 'routing.resync' ? event.bundle : undefined; if (update && (update.writer || update.routes?.primary?.length)) await client.refresh({ ...activeBundle, ...update, database: update.database ?? activeBundle?.database ?? primaryConfig.database, credentials: update.credentials ?? activeBundle?.credentials, routes: update.routes ?? activeBundle?.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?.(); },
|
|
40
|
+
drain(host, timeoutMs = drainTimeoutMs) { const effectiveTimeout = clientDrainTimeout(timeoutMs); const pools = [primaryPool, balancedPool].filter(Boolean); pools.forEach((pool) => pool.drain(host, effectiveTimeout)); return { host, timeoutMs: effectiveTimeout, wait: () => Promise.all(pools.map((pool) => pool.waitForIdle(effectiveTimeout))), forceClose: () => Promise.all(pools.map((pool) => pool.forceClose(host))) }; },
|
|
38
41
|
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 }))); },
|
|
39
42
|
setNodeAvailability(route, host, available) { const pool = route === 'balanced' ? balancedPool : primaryPool; pool?.setAvailability(host, available); },
|
|
40
43
|
bundle: () => activeBundle,
|
|
41
|
-
async close() { await Promise.all([primaryPool.close(), balancedPool?.close()]); },
|
|
44
|
+
async close() { metrics?.stop?.(); await Promise.all([primaryPool.close(), balancedPool?.close()]); },
|
|
42
45
|
classify: classifyQuery,
|
|
43
|
-
config: { primary: redactedProfile(primaryConfig), balanced: balancedConfig && redactedProfile(balancedConfig) }
|
|
46
|
+
telemetry: metrics, config: { primary: redactedProfile(primaryConfig), balanced: balancedConfig && redactedProfile(balancedConfig) }
|
|
44
47
|
};
|
|
45
48
|
log.debug?.('SQL client created', { balanced: Boolean(balancedPool), routing });
|
|
46
49
|
return client;
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { bundleExpired } from '../bundle.mjs';
|
|
2
2
|
import { createNodePool, createRoutePool } from '../pools.mjs';
|
|
3
|
-
|
|
4
|
-
const bundleProfiles = (bundle, route, base) => {
|
|
5
|
-
const routes = bundle.routes?.[route] ?? [];
|
|
6
|
-
return routes.map((node) => ({ ...base, host: node.host, port: node.port, weight: node.weight }));
|
|
7
|
-
};
|
|
3
|
+
import { bundleProfiles } from './bundle-profiles.mjs';
|
|
8
4
|
|
|
9
5
|
const routeProfiles = (bundle, route, fallback, now) => {
|
|
10
6
|
if (!bundle) return [fallback];
|
|
@@ -14,5 +10,5 @@ const routeProfiles = (bundle, route, fallback, now) => {
|
|
|
14
10
|
};
|
|
15
11
|
|
|
16
12
|
export function createRouteFactory({ bundle, now, mysqlLib, log, quarantineMs }) {
|
|
17
|
-
return (route, fallback) => createRoutePool(routeProfiles(bundle, route, fallback, now).map((profile) => createNodePool({ profile, mysqlLib, log, now, quarantineMs })));
|
|
13
|
+
return (route, fallback) => createRoutePool(routeProfiles(bundle, route, fallback, now).map((profile) => createNodePool({ profile, mysqlLib, log, now, quarantineMs })), { preferred: route === 'primary' });
|
|
18
14
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const emptyMetrics = { begin: () => undefined, record: () => {} };
|
|
2
|
+
|
|
3
|
+
export function createTimedOperation({ metrics = emptyMetrics, now = () => Date.now() } = {}) {
|
|
4
|
+
return async function timed(operation) {
|
|
5
|
+
const started = metrics.begin();
|
|
6
|
+
try {
|
|
7
|
+
const result = await operation();
|
|
8
|
+
metrics.record({ latencyMs: started === undefined ? 0 : now() - started });
|
|
9
|
+
return result;
|
|
10
|
+
} catch (error) {
|
|
11
|
+
metrics.record({ latencyMs: started === undefined ? 0 : now() - started, failed: true });
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface ConnectionProfile {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export interface RoutingNode { host: string; port: number | string; weight?: number; }
|
|
13
|
+
export interface WriterAssignment { host: string; port: number | string; }
|
|
13
14
|
export interface RoutingBundle {
|
|
14
15
|
apiVersion?: string;
|
|
15
16
|
database?: string;
|
|
@@ -19,17 +20,22 @@ export interface RoutingBundle {
|
|
|
19
20
|
expiresAt: string;
|
|
20
21
|
refreshAfter?: string;
|
|
21
22
|
routes: { primary?: RoutingNode[]; balanced?: RoutingNode[] };
|
|
23
|
+
writer?: WriterAssignment;
|
|
24
|
+
failover?: WriterAssignment[];
|
|
25
|
+
readers?: WriterAssignment[];
|
|
22
26
|
}
|
|
23
27
|
export interface CredentialProviderResult { user: string; password: string; }
|
|
24
28
|
export type CredentialProvider = (context: { database: string; identity: string | null; route: string }) => Promise<CredentialProviderResult> | CredentialProviderResult;
|
|
25
29
|
export type QueryFunction = (sql: string, values?: unknown) => Promise<any>;
|
|
26
30
|
export interface DbOptions { route?: 'auto' | 'primary' | 'balanced'; connection?: unknown; }
|
|
31
|
+
export interface TelemetrySnapshot { type: 'client.telemetry'; application: string; queries: number; failures: number; retries: number; reconnects: number; failoverCount: number; inflight: number; totalLatencyMs: number; maxLatencyMs: number; avgLatencyMs: number; sentAt: string; }
|
|
32
|
+
export interface Telemetry { begin(): number; record(event?: { latencyMs?: number; failed?: boolean; retry?: boolean; reconnect?: boolean; failover?: boolean }): void; snapshot(): TelemetrySnapshot; start(stream: Pick<RoutingStream, 'sendTelemetry'>): void; stop(): void; }
|
|
27
33
|
|
|
28
34
|
export interface DbClient {
|
|
29
35
|
query(sql: string, values?: unknown, options?: DbOptions): Promise<unknown>;
|
|
30
36
|
execute(sql: string, values?: unknown, options?: DbOptions): Promise<unknown>;
|
|
31
37
|
transaction<T>(callback: (transaction: Pick<DbClient, 'query' | 'execute'>) => Promise<T>): Promise<T>;
|
|
32
|
-
health(route?: 'primary' | 'balanced'): Promise<{ ok: boolean; route: string; latencyMs: number }>;
|
|
38
|
+
health(route?: 'primary' | 'balanced'): Promise<{ ok: boolean; route: string; latencyMs: number; telemetry?: TelemetrySnapshot }>;
|
|
33
39
|
close(): Promise<void>;
|
|
34
40
|
refresh(bundle: RoutingBundle): Promise<{ bundleVersion: number | string | null; refreshRequired: boolean }>;
|
|
35
41
|
bundle(): RoutingBundle | undefined;
|
|
@@ -39,17 +45,19 @@ export interface DbClient {
|
|
|
39
45
|
drain(host: string, timeoutMs?: number): { host: string; timeoutMs: number; wait(): Promise<unknown[]>; forceClose(): Promise<unknown[]> };
|
|
40
46
|
nodeStates(): Array<{ host: string; port: number; route: 'primary' | 'balanced'; state: 'ready' | 'draining' | 'unavailable' | 'recovering'; active: number; available: boolean }>;
|
|
41
47
|
config: { primary: ConnectionProfile; balanced?: ConnectionProfile };
|
|
48
|
+
telemetry?: Telemetry;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
export interface RoutingStream {
|
|
45
52
|
connect(): Promise<void>;
|
|
46
53
|
setOnUpdate(handler: (event: unknown) => void | Promise<void>): void;
|
|
47
54
|
close(): void;
|
|
48
|
-
|
|
55
|
+
sendTelemetry(payload: unknown): void;
|
|
56
|
+
state(): { connected: boolean; mode: 'websocket' | 'rest' | 'disconnected'; expectedVersion: number | string };
|
|
49
57
|
}
|
|
50
58
|
|
|
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>;
|
|
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>;
|
|
59
|
+
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; telemetry?: true | Telemetry }): Promise<DbClient>;
|
|
60
|
+
export function createDbFromEnvironment(options?: { env?: Record<string, string | undefined>; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string; telemetry?: true | Telemetry }): Promise<DbClient>;
|
|
53
61
|
export function classifyQuery(sql: unknown): 'primary' | 'balanced';
|
|
54
62
|
export function routeFor(sql: unknown, requested?: 'auto' | 'primary' | 'balanced'): 'primary' | 'balanced';
|
|
55
63
|
export function validateProfile(profile: ConnectionProfile, name?: string): ConnectionProfile;
|
|
@@ -64,6 +72,12 @@ export function createAdminSql(options: { query: QueryFunction }): { transaction
|
|
|
64
72
|
export function createMigrationRunner(options: { query: QueryFunction; migrations?: Array<{ version: number; name: string; statements: string[] }> }): { status(): Promise<{ applied: unknown[] }>; migrate(): Promise<unknown> };
|
|
65
73
|
export function selectRouteNodes(options: { bundle: RoutingBundle; route?: 'primary' | 'balanced'; now?: number }): RoutingNode[];
|
|
66
74
|
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;
|
|
75
|
+
export function writerAssignment(bundle: RoutingBundle): WriterAssignment;
|
|
76
|
+
export function failoverNodes(bundle: RoutingBundle): WriterAssignment[];
|
|
77
|
+
export function compareBundleVersions(left: number | string | undefined, right: number | string | undefined): number;
|
|
78
|
+
export const CLIENT_DRAIN_TIMEOUT_MS: 45000;
|
|
79
|
+
export function clientDrainTimeout(timeoutMs?: number): number;
|
|
67
80
|
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> };
|
|
68
81
|
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> };
|
|
69
82
|
export function createMaterializer(options?: Record<string, unknown>): unknown;
|
|
83
|
+
export function createTelemetry(options?: { application?: string; intervalMs?: number; now?: () => number; setIntervalImpl?: typeof setInterval; clearIntervalImpl?: typeof clearInterval }): Telemetry;
|
package/src/index.mjs
CHANGED
|
@@ -9,6 +9,10 @@ export { createAdminSql } from './admin/sql.mjs';
|
|
|
9
9
|
export { createMigrationRunner } from './admin/migrations.mjs';
|
|
10
10
|
export { selectRouteNodes } from './routing/node-set.mjs';
|
|
11
11
|
export { createRoutingStream } from './routing/stream-client.mjs';
|
|
12
|
+
export { writerAssignment, failoverNodes } from './routing/assignment.mjs';
|
|
13
|
+
export { compareBundleVersions } from './routing/bundle-version.mjs';
|
|
14
|
+
export { CLIENT_DRAIN_TIMEOUT_MS, clientDrainTimeout } from './lifecycle/drain-policy.mjs';
|
|
12
15
|
export { createQuiesceController } from './lifecycle/quiesce.mjs';
|
|
13
16
|
export { createSqlVerifier } from './verification/sql.mjs';
|
|
14
17
|
export { createMaterializer } from './lifecycle/materializer.mjs';
|
|
18
|
+
export { createTelemetry } from './telemetry.mjs';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const CLIENT_DRAIN_TIMEOUT_MS = 45000;
|
|
2
|
+
|
|
3
|
+
export function clientDrainTimeout(timeoutMs = CLIENT_DRAIN_TIMEOUT_MS) {
|
|
4
|
+
const requested = Number(timeoutMs);
|
|
5
|
+
if (!Number.isFinite(requested) || requested < 0) throw new TypeError('drain timeout must be a non-negative number');
|
|
6
|
+
return Math.min(requested, CLIENT_DRAIN_TIMEOUT_MS);
|
|
7
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { generate as generateSnowflake } from "@eliware/snowflake";
|
|
2
2
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
|
-
export function createMaterializer({ makeTemp = mkdtemp, write = writeFile, remove = rm, id =
|
|
6
|
+
export function createMaterializer({ makeTemp = mkdtemp, write = writeFile, remove = rm, id = generateSnowflake } = {}) {
|
|
7
7
|
return {
|
|
8
8
|
async withFile(content, operation) {
|
|
9
9
|
if (typeof operation !== "function") throw new TypeError("materializer operation is required");
|
package/src/pools/route-pool.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
export function createRoutePool(nodes) {
|
|
1
|
+
export function createRoutePool(nodes, { preferred = false } = {}) {
|
|
2
2
|
let cursor = 0;
|
|
3
3
|
const candidates = () => nodes.filter((node) => node.available);
|
|
4
4
|
const choose = () => {
|
|
5
5
|
const available = candidates();
|
|
6
6
|
if (!available.length) throw new Error('no eligible SQL nodes available');
|
|
7
|
+
if (preferred) return available[0];
|
|
7
8
|
const total = available.reduce((sum, node) => sum + Math.max(0, node.weight), 0);
|
|
8
9
|
if (!total) return available[cursor++ % available.length];
|
|
9
10
|
let target = cursor++ % total;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function writerAssignment(bundle) {
|
|
2
|
+
if (!bundle?.writer?.host) throw new TypeError('routing bundle writer is required');
|
|
3
|
+
return { host: bundle.writer.host, port: Number(bundle.writer.port ?? 3306) };
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function failoverNodes(bundle) {
|
|
7
|
+
if (!Array.isArray(bundle?.failover)) throw new TypeError('routing bundle failover is required');
|
|
8
|
+
return bundle.failover.map(({ host, port = 3306 }) => ({ host, port: Number(port) }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function compareBundleVersions(left, right) {
|
|
2
|
+
if (left === undefined || right === undefined) return 0;
|
|
3
|
+
const a = String(left).match(/\d+/g)?.map(Number);
|
|
4
|
+
const b = String(right).match(/\d+/g)?.map(Number);
|
|
5
|
+
if (!a || !b) return String(left).localeCompare(String(right));
|
|
6
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
7
|
+
const difference = (a[index] ?? 0) - (b[index] ?? 0);
|
|
8
|
+
if (difference) return Math.sign(difference);
|
|
9
|
+
}
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function validateRoutingNode(node, name = 'routing node') {
|
|
2
|
+
if (!node || typeof node !== 'object' || typeof node.host !== 'string' || node.host.trim() === '') throw new TypeError(`${name} host is required`);
|
|
3
|
+
const port = Number(node.port ?? 3306);
|
|
4
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new TypeError(`${name} port is invalid`);
|
|
5
|
+
if (node.weight !== undefined && (!Number.isFinite(Number(node.weight)) || Number(node.weight) < 0)) throw new TypeError(`${name} weight is invalid`);
|
|
6
|
+
return { ...node, host: node.host.trim(), port };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function validateRoutingNodes(nodes, name) {
|
|
10
|
+
if (!Array.isArray(nodes)) throw new TypeError(`${name} must be an array`);
|
|
11
|
+
const validated = nodes.map((node, index) => validateRoutingNode(node, `${name}[${index}]`));
|
|
12
|
+
if (new Set(validated.map(({ host, port }) => `${host}:${port}`)).size !== validated.length) throw new TypeError(`${name} contains duplicate nodes`);
|
|
13
|
+
return validated;
|
|
14
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { log as defaultLog } from '@eliware/common';
|
|
2
|
+
import { compareBundleVersions } from './bundle-version.mjs';
|
|
2
3
|
|
|
3
4
|
export function createRoutingStream({ endpoint, token, application = 'default', fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, heartbeatMs = 45000, now = () => Date.now() } = {}) {
|
|
4
5
|
if (!endpoint || typeof fetchBundle !== 'function') throw new TypeError('endpoint and fetchBundle are required');
|
|
@@ -14,15 +15,17 @@ export function createRoutingStream({ endpoint, token, application = 'default',
|
|
|
14
15
|
socket.onopen = () => { mode = 'websocket'; delay = reconnectMs; heartbeat = setInterval(() => socket?.send?.(JSON.stringify({ type: 'heartbeat', sentAt: now() })), heartbeatMs); };
|
|
15
16
|
socket.onmessage = async ({ data }) => {
|
|
16
17
|
try {
|
|
17
|
-
const event = JSON.parse(data); const version =
|
|
18
|
-
if (version && expectedVersion && version <=
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
const event = JSON.parse(data); const version = event.version;
|
|
19
|
+
if (version !== undefined && expectedVersion !== 0 && compareBundleVersions(version, expectedVersion) <= 0) return;
|
|
20
|
+
const numericVersion = Number(version); const numericExpected = Number(expectedVersion);
|
|
21
|
+
if (Number.isInteger(numericVersion) && Number.isInteger(numericExpected) && numericExpected > 0 && numericVersion > numericExpected + 1) await fallback();
|
|
22
|
+
if (version !== undefined && (expectedVersion === 0 || compareBundleVersions(version, expectedVersion) > 0)) expectedVersion = version;
|
|
23
|
+
updateHandler?.(event);
|
|
21
24
|
} catch (error) { onError?.(error); }
|
|
22
25
|
};
|
|
23
26
|
socket.onerror = (error) => { onError?.(error); };
|
|
24
27
|
socket.onclose = () => { clearInterval(heartbeat); heartbeat = undefined; socket = undefined; mode = 'disconnected'; if (!closed) { void fallback(); schedule(); } };
|
|
25
28
|
} catch (error) { mode = 'disconnected'; onError?.(error); await fallback(); schedule(); }
|
|
26
29
|
}
|
|
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 }) };
|
|
30
|
+
return { connect, sendTelemetry: (payload) => { if (socket?.readyState === 1) socket.send(JSON.stringify(payload)); }, setOnUpdate: (handler) => { updateHandler = handler; }, close: () => { closed = true; mode = 'disconnected'; clearTimeout(timer); clearInterval(heartbeat); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, mode, expectedVersion }) };
|
|
28
31
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function createTelemetry({ application = 'default', intervalMs = 1000, now = () => Date.now(), setIntervalImpl = setInterval, clearIntervalImpl = clearInterval } = {}) {
|
|
2
|
+
const stats = { queries: 0, failures: 0, retries: 0, reconnects: 0, failoverCount: 0, inflight: 0, totalLatencyMs: 0, maxLatencyMs: 0 };
|
|
3
|
+
let timer;
|
|
4
|
+
const begin = () => { stats.inflight += 1; return now(); };
|
|
5
|
+
const record = ({ latencyMs = 0, failed = false, retry = false, reconnect = false, failover = false } = {}) => { stats.queries += 1; stats.inflight = Math.max(0, stats.inflight - 1); stats.totalLatencyMs += latencyMs; stats.maxLatencyMs = Math.max(stats.maxLatencyMs, latencyMs); if (failed) stats.failures += 1; if (retry) stats.retries += 1; if (reconnect) stats.reconnects += 1; if (failover) stats.failoverCount += 1; };
|
|
6
|
+
const snapshot = () => ({ type: 'client.telemetry', application, ...stats, avgLatencyMs: stats.queries ? stats.totalLatencyMs / stats.queries : 0, sentAt: new Date(now()).toISOString() });
|
|
7
|
+
return { begin, record, snapshot, start(stream) { if (timer) return; timer = setIntervalImpl(() => stream.sendTelemetry?.(snapshot()), intervalMs); timer.unref?.(); }, stop() { if (timer) clearIntervalImpl(timer); timer = undefined; } };
|
|
8
|
+
}
|