@onlineapps/service-wrapper 2.2.9 → 2.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/service-wrapper",
3
- "version": "2.2.9",
3
+ "version": "2.3.1",
4
4
  "description": "Thin orchestration layer for microservices - delegates all infrastructure concerns to specialized connectors",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -30,10 +30,10 @@
30
30
  "@onlineapps/conn-infra-error-handler": "1.0.9",
31
31
  "@onlineapps/conn-infra-mq": "1.1.69",
32
32
  "@onlineapps/conn-orch-api-mapper": "1.0.30",
33
- "@onlineapps/conn-orch-cookbook": "2.0.37",
34
- "@onlineapps/conn-orch-orchestrator": "1.0.105",
35
- "@onlineapps/conn-orch-registry": "1.1.54",
36
- "@onlineapps/conn-orch-validator": "2.0.31",
33
+ "@onlineapps/conn-orch-cookbook": "2.0.38",
34
+ "@onlineapps/conn-orch-orchestrator": "1.0.106",
35
+ "@onlineapps/conn-orch-registry": "1.1.55",
36
+ "@onlineapps/conn-orch-validator": "2.0.32",
37
37
  "@onlineapps/monitoring-core": "1.0.21",
38
38
  "@onlineapps/service-common": "1.0.18",
39
39
  "@onlineapps/runtime-config": "1.0.2"
@@ -44,7 +44,7 @@ const CONFIG_REGISTRY = {
44
44
  description: 'Operations specification (API contract)'
45
45
  },
46
46
  VALIDATION_PROOF: {
47
- paths: ['config/runtime/validation-proof.json', 'conn-runtime/validation-proof.json'],
47
+ paths: ['conn-runtime/validation-proof.json'],
48
48
  required: false,
49
49
  description: 'Pre-validation proof from cookbook tests'
50
50
  }
@@ -25,9 +25,20 @@ const ErrorHandlerConnector = require('@onlineapps/conn-infra-error-handler');
25
25
  const { ValidationOrchestrator } = require('@onlineapps/conn-orch-validator');
26
26
  const runtimeCfg = require('./config');
27
27
 
28
+ const RUNTIME_DIR = 'conn-runtime';
29
+ const PROOF_RELATIVE_PATH = `${RUNTIME_DIR}/validation-proof.json`;
30
+
28
31
  const REVALIDATION_FAST_BACKOFF_MS = [30000, 60000, 120000, 300000, 600000, 1800000];
29
- const REVALIDATION_DAILY_MS = 86400000;
30
- const REVALIDATION_STATE = { IDLE: 'idle', FAST_BACKOFF: 'fast_backoff', DAILY_BACKOFF: 'daily_backoff', SUCCEEDED: 'succeeded' };
32
+ const REVALIDATION_HOURLY_MS = 3600000;
33
+ const REVALIDATION_HOURLY_MAX = 24;
34
+ const REVALIDATION_SLOW_MS = 21600000;
35
+ const REVALIDATION_STATE = {
36
+ IDLE: 'idle',
37
+ FAST_BACKOFF: 'fast_backoff',
38
+ HOURLY_BACKOFF: 'hourly_backoff',
39
+ SLOW_BACKOFF: 'slow_backoff',
40
+ SUCCEEDED: 'succeeded'
41
+ };
31
42
 
32
43
  const INFRA_QUEUE_OWNERS = {
33
44
  'workflow.init': 'Gateway (api_gateway)',
@@ -82,7 +93,6 @@ class ServiceWrapper {
82
93
  this._revalidationState = REVALIDATION_STATE.IDLE;
83
94
  this._revalidationAttempt = 0;
84
95
  this._revalidationTimer = null;
85
- this._infraInvalidationConsumerTag = null;
86
96
  }
87
97
 
88
98
  /**
@@ -163,14 +173,8 @@ class ServiceWrapper {
163
173
  throw new Error(`Failed to start workflow listeners for ${serviceName}: ${listenerError.message}`);
164
174
  }
165
175
 
166
- // Phase 0.10: Subscribe to infrastructure invalidation events
167
- try {
168
- await this._subscribeToInfraInvalidation(serviceName);
169
- } catch (subErr) {
170
- this.logger?.warn('[ServiceWrapper][infra.invalidation] Failed to subscribe to invalidation events (non-critical)', {
171
- error: subErr.message
172
- });
173
- }
176
+ // Revalidation requests from Registry are handled via RegistryClient 'revalidate' event
177
+ // (subscribed in _initializeRegistry after successful registration)
174
178
  }
175
179
 
176
180
  /**
@@ -439,7 +443,6 @@ class ServiceWrapper {
439
443
  this.workflowInitConsumerTag = null;
440
444
  this.workflowControlConsumerTag = null;
441
445
  this.serviceWorkflowConsumerTag = null;
442
- this._infraInvalidationConsumerTag = null;
443
446
  if (this._revalidationTimer) {
444
447
  clearTimeout(this._revalidationTimer);
445
448
  this._revalidationTimer = null;
@@ -997,6 +1000,15 @@ class ServiceWrapper {
997
1000
  throw new Error(`Failed to register service ${serviceName}: ${error.message}`);
998
1001
  }
999
1002
 
1003
+ // Listen for revalidation requests from Registry (infra version changes)
1004
+ this.registryClient.on('revalidate', (payload) => {
1005
+ this.logger?.info('[ServiceWrapper][revalidate] Revalidation requested by Registry', {
1006
+ reason: payload.reason,
1007
+ infraFingerprint: payload.infraFingerprint?.substring(0, 16)
1008
+ });
1009
+ this._deleteProofAndRevalidate();
1010
+ });
1011
+
1000
1012
  // Start heartbeat with fail-fast on persistent MQ failure
1001
1013
  const heartbeatInterval = this.config.wrapper?.registry?.heartbeatInterval;
1002
1014
  if (typeof heartbeatInterval !== 'number' || Number.isNaN(heartbeatInterval) || heartbeatInterval <= 0) {
@@ -1271,7 +1283,7 @@ class ServiceWrapper {
1271
1283
  return { status: 'no_service_root' };
1272
1284
  }
1273
1285
 
1274
- const proofPath = path.join(this.serviceRoot, 'conn-runtime', 'validation-proof.json');
1286
+ const proofPath = path.join(this.serviceRoot, PROOF_RELATIVE_PATH);
1275
1287
 
1276
1288
  if (!fs.existsSync(proofPath)) {
1277
1289
  return { status: 'no_proof' };
@@ -1814,6 +1826,28 @@ class ServiceWrapper {
1814
1826
  }
1815
1827
  }
1816
1828
 
1829
+ // Extension 1: specificationEndpoint must match an Express route
1830
+ // See: docs/standards/api-versioning-contract.md
1831
+ const specEndpoint = this.config.service?.specificationEndpoint;
1832
+ if (specEndpoint && !routeSet.has(`GET:${specEndpoint}`)) {
1833
+ errors.push(
1834
+ `specificationEndpoint '${specEndpoint}' not found in Express routes — ` +
1835
+ `config claims this path but no GET route is registered`
1836
+ );
1837
+ }
1838
+
1839
+ // Extension 3: operations.json endpoints under /api/ must be versioned
1840
+ // See: docs/standards/api-versioning-contract.md
1841
+ const versionPattern = /^\/api\/v\d+\//;
1842
+ for (const [opName, opDef] of Object.entries(ops)) {
1843
+ if (opDef.endpoint?.startsWith('/api') && !versionPattern.test(opDef.endpoint)) {
1844
+ errors.push(
1845
+ `Operation '${opName}' endpoint '${opDef.endpoint}' missing version — ` +
1846
+ `expected /api/v{N}/... (see docs/standards/api-versioning-contract.md)`
1847
+ );
1848
+ }
1849
+ }
1850
+
1817
1851
  if (errors.length > 0) {
1818
1852
  throw new Error(
1819
1853
  `[ServiceWrapper] Operations-Routes alignment failed (${errors.length} issue${errors.length > 1 ? 's' : ''}):\n` +
@@ -1823,67 +1857,23 @@ class ServiceWrapper {
1823
1857
  }
1824
1858
 
1825
1859
  /**
1826
- * Subscribe to infra.invalidation events on the infrastructure.health.events fanout exchange.
1827
- * When received, deletes local validation proof and triggers re-validation with backoff.
1860
+ * Delete local validation proof and trigger re-validation.
1861
+ * Called when Registry sends a revalidation request (infra version change).
1828
1862
  * @private
1829
1863
  */
1830
- async _subscribeToInfraInvalidation(serviceName) {
1831
- if (!this.mqClient?._transport?.channel) {
1832
- this.logger?.debug('[ServiceWrapper][infra.invalidation] No MQ channel available — skipping subscription');
1833
- return;
1834
- }
1835
-
1836
- const channel = this.mqClient._transport.channel;
1837
- const exchangeName = 'infrastructure.health.events';
1838
-
1839
- await channel.assertExchange(exchangeName, 'fanout', { durable: true });
1840
-
1841
- const q = await channel.assertQueue('', { exclusive: true, autoDelete: true });
1842
-
1843
- await channel.bindQueue(q.queue, exchangeName, '');
1844
-
1845
- const { consumerTag } = await channel.consume(q.queue, async (msg) => {
1846
- if (!msg) return;
1847
-
1848
- try {
1849
- const event = JSON.parse(msg.content.toString());
1850
-
1851
- if (event.type !== 'infra.invalidation') {
1852
- channel.ack(msg);
1853
- return;
1854
- }
1855
-
1856
- this.logger?.info('[ServiceWrapper][infra.invalidation] Infrastructure change detected, scheduling re-validation', {
1857
- fingerprint: event.fingerprint?.substring(0, 16) + '...',
1858
- timestamp: event.timestamp
1859
- });
1860
-
1861
- // Delete local validation proof
1862
- const fs = require('fs');
1863
- const path = require('path');
1864
- if (this.serviceRoot) {
1865
- const proofPath = path.join(this.serviceRoot, 'conn-runtime', 'validation-proof.json');
1866
- if (fs.existsSync(proofPath)) {
1867
- fs.unlinkSync(proofPath);
1868
- this.logger?.info('[ServiceWrapper][infra.invalidation] Deleted local validation proof', { proofPath });
1869
- }
1870
- }
1871
-
1872
- this._revalidateWithBackoff();
1864
+ _deleteProofAndRevalidate() {
1865
+ const fs = require('fs');
1866
+ const path = require('path');
1873
1867
 
1874
- } catch (parseErr) {
1875
- this.logger?.warn('[ServiceWrapper][infra.invalidation] Failed to parse event', { error: parseErr.message });
1868
+ if (this.serviceRoot) {
1869
+ const proofPath = path.join(this.serviceRoot, PROOF_RELATIVE_PATH);
1870
+ if (fs.existsSync(proofPath)) {
1871
+ fs.unlinkSync(proofPath);
1872
+ this.logger?.info('[ServiceWrapper][revalidate] Deleted local validation proof', { proofPath });
1876
1873
  }
1874
+ }
1877
1875
 
1878
- channel.ack(msg);
1879
- });
1880
-
1881
- this._infraInvalidationConsumerTag = consumerTag;
1882
- this.logger?.info('[ServiceWrapper][infra.invalidation] Subscribed to infrastructure invalidation events', {
1883
- exchange: exchangeName,
1884
- queue: q.queue,
1885
- consumerTag
1886
- });
1876
+ this._revalidateWithBackoff();
1887
1877
  }
1888
1878
 
1889
1879
  /**
@@ -1893,7 +1883,8 @@ class ServiceWrapper {
1893
1883
  */
1894
1884
  _revalidateWithBackoff() {
1895
1885
  if (this._revalidationState === REVALIDATION_STATE.FAST_BACKOFF ||
1896
- this._revalidationState === REVALIDATION_STATE.DAILY_BACKOFF) {
1886
+ this._revalidationState === REVALIDATION_STATE.HOURLY_BACKOFF ||
1887
+ this._revalidationState === REVALIDATION_STATE.SLOW_BACKOFF) {
1897
1888
  this.logger?.info('[ServiceWrapper][revalidation] Ignoring duplicate invalidation — re-validation cycle already active', {
1898
1889
  state: this._revalidationState,
1899
1890
  attempt: this._revalidationAttempt
@@ -1903,27 +1894,44 @@ class ServiceWrapper {
1903
1894
 
1904
1895
  this._revalidationState = REVALIDATION_STATE.FAST_BACKOFF;
1905
1896
  this._revalidationAttempt = 0;
1897
+ this._hourlyAttempts = 0;
1906
1898
  this._scheduleRevalidationAttempt();
1907
1899
  }
1908
1900
 
1909
1901
  /**
1910
- * Schedule the next re-validation attempt based on current state and attempt count.
1902
+ * Schedule the next re-validation attempt.
1903
+ * Backoff phases: 6 fast (30s-30min) -> hourly (max 24h) -> every 6h (forever).
1904
+ * At transition from hourly to slow: deregister + shutdown.
1911
1905
  * @private
1912
1906
  */
1913
1907
  _scheduleRevalidationAttempt() {
1914
1908
  let delayMs;
1915
1909
 
1916
- if (this._revalidationState === REVALIDATION_STATE.DAILY_BACKOFF) {
1917
- delayMs = REVALIDATION_DAILY_MS;
1918
- } else if (this._revalidationAttempt < REVALIDATION_FAST_BACKOFF_MS.length) {
1919
- delayMs = REVALIDATION_FAST_BACKOFF_MS[this._revalidationAttempt];
1910
+ if (this._revalidationState === REVALIDATION_STATE.FAST_BACKOFF) {
1911
+ if (this._revalidationAttempt < REVALIDATION_FAST_BACKOFF_MS.length) {
1912
+ delayMs = REVALIDATION_FAST_BACKOFF_MS[this._revalidationAttempt];
1913
+ } else {
1914
+ this._revalidationState = REVALIDATION_STATE.HOURLY_BACKOFF;
1915
+ this._hourlyAttempts = 0;
1916
+ delayMs = REVALIDATION_HOURLY_MS;
1917
+ this.logger?.warn(`[ServiceWrapper][revalidation] Fast retries exhausted (${this._revalidationAttempt} attempts) — switching to hourly retry`);
1918
+ }
1919
+ } else if (this._revalidationState === REVALIDATION_STATE.HOURLY_BACKOFF) {
1920
+ if (this._hourlyAttempts < REVALIDATION_HOURLY_MAX) {
1921
+ delayMs = REVALIDATION_HOURLY_MS;
1922
+ } else {
1923
+ this.logger?.error(
1924
+ `[ServiceWrapper][revalidation] All revalidation attempts exhausted ` +
1925
+ `(${REVALIDATION_FAST_BACKOFF_MS.length} fast + ${REVALIDATION_HOURLY_MAX} hourly) — deregistering and shutting down`
1926
+ );
1927
+ this._deregisterAndShutdown();
1928
+ return;
1929
+ }
1920
1930
  } else {
1921
- this._revalidationState = REVALIDATION_STATE.DAILY_BACKOFF;
1922
- delayMs = REVALIDATION_DAILY_MS;
1923
- this.logger?.warn(`[ServiceWrapper][revalidation] Fast retries exhausted (${this._revalidationAttempt} attempts) - switching to daily retry`);
1931
+ delayMs = REVALIDATION_SLOW_MS;
1924
1932
  }
1925
1933
 
1926
- this.logger?.info(`[ServiceWrapper][revalidation] Next attempt in ${delayMs}ms`, {
1934
+ this.logger?.info(`[ServiceWrapper][revalidation] Next attempt in ${Math.round(delayMs / 1000)}s`, {
1927
1935
  attempt: this._revalidationAttempt + 1,
1928
1936
  state: this._revalidationState,
1929
1937
  delayMs
@@ -1932,16 +1940,37 @@ class ServiceWrapper {
1932
1940
  this._revalidationTimer = setTimeout(() => this._executeRevalidation(), delayMs);
1933
1941
  }
1934
1942
 
1943
+ /**
1944
+ * Deregister from Registry and exit. Container orchestrator will restart.
1945
+ * @private
1946
+ */
1947
+ async _deregisterAndShutdown() {
1948
+ try {
1949
+ if (this.registryClient) {
1950
+ await this.registryClient.deregister();
1951
+ this.logger?.info('[ServiceWrapper][revalidation] Deregistered from registry');
1952
+ }
1953
+ } catch (err) {
1954
+ this.logger?.error('[ServiceWrapper][revalidation] Failed to deregister before shutdown', { error: err.message });
1955
+ }
1956
+
1957
+ process.exit(1);
1958
+ }
1959
+
1935
1960
  /**
1936
1961
  * Execute a single re-validation attempt.
1937
1962
  * @private
1938
1963
  */
1939
1964
  async _executeRevalidation() {
1940
1965
  this._revalidationAttempt++;
1941
- const maxFast = REVALIDATION_FAST_BACKOFF_MS.length;
1966
+ if (this._revalidationState === REVALIDATION_STATE.HOURLY_BACKOFF) {
1967
+ this._hourlyAttempts++;
1968
+ }
1942
1969
  const startTime = Date.now();
1943
1970
 
1944
- this.logger?.info(`[ServiceWrapper][revalidation] Attempt ${this._revalidationAttempt}/${maxFast} starting`);
1971
+ this.logger?.info(`[ServiceWrapper][revalidation] Attempt ${this._revalidationAttempt} starting`, {
1972
+ state: this._revalidationState
1973
+ });
1945
1974
 
1946
1975
  try {
1947
1976
  const serviceName = this.config.service?.name;
@@ -1964,7 +1993,6 @@ class ServiceWrapper {
1964
1993
 
1965
1994
  this.validationProof = result.proof;
1966
1995
 
1967
- // Re-register with fresh proof
1968
1996
  if (this.registryClient) {
1969
1997
  const serviceInfo = {
1970
1998
  name: serviceName,
@@ -1983,26 +2011,15 @@ class ServiceWrapper {
1983
2011
  this._revalidationTimer = null;
1984
2012
  this.logger?.info(`[ServiceWrapper][revalidation] Success after ${this._revalidationAttempt} attempts (${elapsed}ms)`);
1985
2013
 
1986
- // Clean up failure tracking file
1987
2014
  this._clearValidationFailureFile();
1988
2015
 
1989
2016
  } catch (error) {
1990
2017
  const elapsed = Date.now() - startTime;
1991
- const nextDelay = this._revalidationState === REVALIDATION_STATE.DAILY_BACKOFF
1992
- ? REVALIDATION_DAILY_MS
1993
- : (this._revalidationAttempt < maxFast
1994
- ? REVALIDATION_FAST_BACKOFF_MS[this._revalidationAttempt]
1995
- : REVALIDATION_DAILY_MS);
1996
-
1997
- if (this._revalidationState === REVALIDATION_STATE.DAILY_BACKOFF) {
1998
- this.logger?.warn(`[ServiceWrapper][revalidation] Daily retry attempt ${this._revalidationAttempt} - ${error.message}`);
1999
- } else {
2000
- this.logger?.warn(`[ServiceWrapper][revalidation] Attempt ${this._revalidationAttempt} failed: ${error.message} - next retry in ${nextDelay}ms`, {
2001
- attempt: this._revalidationAttempt,
2002
- elapsed,
2003
- error: error.message
2004
- });
2005
- }
2018
+ this.logger?.warn(`[ServiceWrapper][revalidation] Attempt ${this._revalidationAttempt} failed (${elapsed}ms): ${error.message}`, {
2019
+ attempt: this._revalidationAttempt,
2020
+ state: this._revalidationState,
2021
+ error: error.message
2022
+ });
2006
2023
 
2007
2024
  this._scheduleRevalidationAttempt();
2008
2025
  }
@@ -2020,7 +2037,7 @@ class ServiceWrapper {
2020
2037
 
2021
2038
  if (!this.serviceRoot) return null;
2022
2039
 
2023
- const failurePath = path.join(this.serviceRoot, 'conn-runtime', 'validation-failure.json');
2040
+ const failurePath = path.join(this.serviceRoot, RUNTIME_DIR, 'validation-failure.json');
2024
2041
  try {
2025
2042
  if (!fs.existsSync(failurePath)) return null;
2026
2043
  return JSON.parse(fs.readFileSync(failurePath, 'utf8'));
@@ -2042,12 +2059,12 @@ class ServiceWrapper {
2042
2059
 
2043
2060
  if (!this.serviceRoot) return;
2044
2061
 
2045
- const runtimeDir = path.join(this.serviceRoot, 'conn-runtime');
2046
- if (!fs.existsSync(runtimeDir)) {
2047
- fs.mkdirSync(runtimeDir, { recursive: true });
2062
+ const rtDir = path.join(this.serviceRoot, RUNTIME_DIR);
2063
+ if (!fs.existsSync(rtDir)) {
2064
+ fs.mkdirSync(rtDir, { recursive: true });
2048
2065
  }
2049
2066
 
2050
- const failurePath = path.join(runtimeDir, 'validation-failure.json');
2067
+ const failurePath = path.join(rtDir, 'validation-failure.json');
2051
2068
  const data = {
2052
2069
  lastAttempt: new Date().toISOString(),
2053
2070
  attemptCount,
@@ -2068,7 +2085,7 @@ class ServiceWrapper {
2068
2085
 
2069
2086
  if (!this.serviceRoot) return;
2070
2087
 
2071
- const failurePath = path.join(this.serviceRoot, 'conn-runtime', 'validation-failure.json');
2088
+ const failurePath = path.join(this.serviceRoot, RUNTIME_DIR, 'validation-failure.json');
2072
2089
  try {
2073
2090
  if (fs.existsSync(failurePath)) {
2074
2091
  fs.unlinkSync(failurePath);
@@ -2192,15 +2209,15 @@ class ServiceWrapper {
2192
2209
  const lastAttempt = new Date(failureData.lastAttempt).getTime();
2193
2210
  const sinceLastAttempt = Date.now() - lastAttempt;
2194
2211
 
2195
- if (sinceLastAttempt < REVALIDATION_DAILY_MS) {
2196
- const remainingMs = REVALIDATION_DAILY_MS - sinceLastAttempt;
2212
+ if (sinceLastAttempt < REVALIDATION_SLOW_MS) {
2213
+ const remainingMs = REVALIDATION_SLOW_MS - sinceLastAttempt;
2197
2214
  const remainingMin = Math.round(remainingMs / 60000);
2198
2215
  this.logger?.info(
2199
2216
  `[ServiceWrapper][startup] Previous validation failures detected (${failureData.attemptCount} attempts since ${failureData.firstFailure}) - waiting ${remainingMin}min before retry`
2200
2217
  );
2201
2218
  await new Promise(resolve => setTimeout(resolve, Math.min(remainingMs, 300000)));
2202
2219
  } else {
2203
- this.logger?.info('[ServiceWrapper][startup] Previous failures detected, but 24h elapsed - retrying fresh');
2220
+ this.logger?.info('[ServiceWrapper][startup] Previous failures detected, but cooldown elapsed - retrying fresh');
2204
2221
  }
2205
2222
  }
2206
2223
 
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ // See: docs/standards/tenant-context-contract.md
3
4
  /**
4
5
  * Tenant context middleware — requires x-tenant-id + x-workspace-id headers.
5
6
  *