Skip to content

Commit 8d3230e

Browse files
committed
Replay retained logs on remote enrollment
1 parent b8b69b8 commit 8d3230e

23 files changed

Lines changed: 721 additions & 73 deletions

hypaware-core/plugins-workspace/central/index.js

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
import path from 'node:path'
44

5-
import { createInstanceWatermarkStore } from '../../../src/core/sinks/incremental.js'
5+
import { createSinkWatermarkStore } from '../../../src/core/sinks/watermarks.js'
66

77
import { validateCentralConfig } from './src/config.js'
88
import { createConfigPullLoop } from './src/config_client.js'
99
import { IdentityClient } from './src/identity_client.js'
10-
import { createDatasetRolloutStore } from './src/rollout.js'
10+
import { bindDestinationState, createDatasetRolloutStore, markDestinationStateReady } from './src/rollout.js'
1111
import { createForwardSink, initializeOpenDatasetRollouts } from './src/sink.js'
1212

1313
/**
@@ -63,22 +63,48 @@ export async function activate(ctx) {
6363
hyp_identity_source: source,
6464
})
6565

66-
// Per-(sink instance, partition) incremental-read watermarks. The plugin
67-
// `stateDir` is per-PLUGIN, so two `@hypaware/central` instances would
68-
// share, and clobber, one watermark file and skip each other's rows;
69-
// `createInstanceWatermarkStore` namespaces by the instance name, matching
70-
// local-fs/s3. Each forward instance then reads only rows added since its
71-
// own last successful export.
72-
// @ref LLP 0040#watermark-contract [implements]: one watermark per (sink instance, partition), scoped by instance name
73-
const watermarks = createInstanceWatermarkStore({ paths: sinkCtx.paths, instanceName: sinkCtx.name })
74-
const rollouts = createDatasetRolloutStore({ paths: sinkCtx.paths, instanceName: sinkCtx.name })
66+
// Bind progress before creating either state store. Existing unscoped
67+
// progress is adopted once for the current destination; a new origin/org
68+
// gets an isolated scope durably marked for retained-history replay.
69+
// @ref LLP 0315#destination-identity [implements]: watermarks and rollout manifests share one destination-scoped state root
70+
let destinationState = await bindDestinationState({
71+
paths: sinkCtx.paths,
72+
instanceName: sinkCtx.name,
73+
destination: identityClient.getDestination(),
74+
})
75+
sinkCtx.log.info('central.destination.bound', {
76+
hyp_sink_instance: sinkCtx.name,
77+
destination_origin: destinationState.destination.origin,
78+
destination_org: destinationState.destination.org,
79+
destination_phase: destinationState.phase,
80+
adopted_legacy_progress: destinationState.adoptedLegacy,
81+
})
7582

76-
// Establish open-dataset rollout state during sink creation. On an
77-
// upgraded machine this baselines partitions already on disk; on a cold
78-
// machine it durably records an empty dataset before the first captured
79-
// row can be mistaken for rollout history.
83+
const watermarks = createSinkWatermarkStore({ stateDir: destinationState.stateDir })
84+
const rollouts = createDatasetRolloutStore({ stateDir: destinationState.stateDir })
85+
86+
// Establish open-dataset rollout state during sink creation. An existing
87+
// destination's software rollout baselines current partitions; a new
88+
// destination starts them at zero so retained eligible history forwards.
89+
// An empty dataset still gets a durable manifest before its first row.
8090
// @ref LLP 0307#rollout-instant [implements]: initialize dataset rollout state before scheduled exports can observe a first partition
81-
await initializeOpenDatasetRollouts({ query, storage, watermarks, rollouts, log: sinkCtx.log })
91+
// @ref LLP 0315#new-destination-replay [implements]: a newly bound destination initializes eligible open datasets for retained-history replay
92+
await initializeOpenDatasetRollouts({
93+
query,
94+
storage,
95+
watermarks,
96+
rollouts,
97+
log: sinkCtx.log,
98+
replayRetainedHistory: destinationState.phase === 'initializing-history',
99+
})
100+
if (destinationState.phase === 'initializing-history') {
101+
destinationState = await markDestinationStateReady(destinationState)
102+
sinkCtx.log.info('central.destination.ready', {
103+
hyp_sink_instance: sinkCtx.name,
104+
destination_origin: destinationState.destination.origin,
105+
destination_org: destinationState.destination.org,
106+
})
107+
}
82108

83109
const sink = createForwardSink({
84110
config,

hypaware-core/plugins-workspace/central/proto.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ release that drops `/v1/` ships. Clients send no version header.
2424

2525
The gateway holds one long-lived JWT issued by the central server. The
2626
JWT's `sub` is the gateway id; the kernel persists `{ jwt, expires_at,
27-
gateway_id }` to `<plugin.stateDir>/identity.json` (mode 0600,
27+
gateway_id, org }` to `<plugin.stateDir>/identity.json` (mode 0600,
2828
atomic tmp+rename).
2929

3030
### POST `/v1/identity/bootstrap`
@@ -43,10 +43,16 @@ Request:
4343
Response 200:
4444

4545
```json
46-
{ "jwt": "<base64url.signed.jwt>", "expires_at": 1814400000 }
46+
{
47+
"jwt": "<base64url.signed.jwt>",
48+
"expires_at": 1814400000,
49+
"org": "acme.example"
50+
}
4751
```
4852

49-
`expires_at` is a Unix epoch second.
53+
`expires_at` is a Unix epoch second. `org` is the stable server-assigned
54+
organization identifier used with the server origin to scope export progress;
55+
it is an empty string for a single-org server without organization enforcement.
5056

5157
Response 401 / 4xx: `{ "error": "<kind>" }`. Gateway aborts; operator
5258
must issue a new bootstrap token.

hypaware-core/plugins-workspace/central/src/identity_client.js

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import fs from 'node:fs'
55
import { atomicWriteJsonSync, isPlainObject, sha256Hex } from 'hypaware/core/util'
66

77
/**
8-
* @import { AcquireSource, PersistedIdentity } from './types.js'
8+
* @import { AcquireSource, PersistedIdentity, RemoteDestination } from './types.js'
99
*/
1010

1111
/**
@@ -110,6 +110,14 @@ export class IdentityClient {
110110
)
111111
}
112112
this.identity = persisted
113+
// An identity written before destination-scoped progress has no stable
114+
// organization beside it. Refresh once against the upgraded server
115+
// before any sink state is selected, then persist the authoritative org.
116+
// @ref LLP 0315#destination-identity [implements]: legacy identities acquire the server-assigned org before export progress is bound to a destination
117+
if (persisted.org === undefined) {
118+
await this.refresh()
119+
return 'refreshed'
120+
}
113121
const remainingSec = persisted.expires_at - Math.floor(this.now() / 1000)
114122
if (remainingSec <= REFRESH_WINDOW_SECONDS) {
115123
await this.refresh()
@@ -200,13 +208,23 @@ export class IdentityClient {
200208
throw new Error(`identity refresh failed: ${await readErrorDetail(response)}`)
201209
}
202210
const parsed = await readJsonResponse(response, 'refresh')
203-
const identity = identityFromPayload(parsed, this.identity.gateway_id)
211+
const previous = this.identity
212+
const identity = identityFromPayload(parsed, previous.gateway_id)
213+
// A credential rotation must not silently move a running sink to another
214+
// destination. A legacy identity has no prior org and adopts the response;
215+
// every subsequent refresh must preserve it exactly.
216+
// @ref LLP 0315#destination-identity [constrained-by]: credential refresh preserves destination identity; an org change requires a new enrollment and state scope
217+
if (previous.org !== undefined && identity.org !== previous.org) {
218+
throw new Error(
219+
`identity refresh failed: central server changed organization from '${previous.org}' to '${identity.org}'`
220+
)
221+
}
204222
// Preserve the mint provenance across refresh; the bootstrap token is
205223
// typically absent in steady state, so re-derive it from the prior
206224
// persisted identity rather than recomputing.
207-
identity.central_url = this.identity.central_url
208-
identity.bootstrap_token_fp = this.identity.bootstrap_token_fp
209-
if (this.identity.origin !== undefined) identity.origin = this.identity.origin
225+
identity.central_url = previous.central_url
226+
identity.bootstrap_token_fp = previous.bootstrap_token_fp
227+
if (previous.origin !== undefined) identity.origin = previous.origin
210228
this.identity = identity
211229
writePersistedFile(this.persistedPath, identity)
212230
}
@@ -230,6 +248,21 @@ export class IdentityClient {
230248
}
231249
return this.identity.jwt
232250
}
251+
252+
/**
253+
* Return the authenticated destination identity used to scope export state.
254+
* `acquire()` must run first so a legacy identity has already refreshed its
255+
* missing organization.
256+
*
257+
* @returns {RemoteDestination}
258+
*/
259+
getDestination() {
260+
if (!this.identity || this.identity.org === undefined) {
261+
throw new Error('identity destination not acquired - call acquire() first')
262+
}
263+
// @ref LLP 0315#destination-identity [implements]: progress keys on canonical server origin plus stable organization, never gateway credentials
264+
return { origin: new URL(this.centralUrl).origin, org: this.identity.org }
265+
}
233266
}
234267

235268
/**
@@ -255,7 +288,7 @@ function readPersistedFile(filePath) {
255288
if (!isPlainObject(parsed)) {
256289
throw new Error(`persisted identity ${filePath} must be an object`)
257290
}
258-
const { jwt, expires_at, gateway_id, central_url, bootstrap_token_fp, origin } =
291+
const { jwt, expires_at, gateway_id, org, central_url, bootstrap_token_fp, origin } =
259292
/** @type {Record<string, unknown>} */ (parsed)
260293
if (typeof jwt !== 'string' || jwt.length === 0) {
261294
throw new Error(`persisted identity ${filePath}: missing or invalid jwt`)
@@ -268,6 +301,10 @@ function readPersistedFile(filePath) {
268301
}
269302
/** @type {PersistedIdentity} */
270303
const identity = { jwt, expires_at, gateway_id }
304+
if (org !== undefined && typeof org !== 'string') {
305+
throw new Error(`persisted identity ${filePath}: invalid org`)
306+
}
307+
if (typeof org === 'string') identity.org = org
271308
if (typeof central_url === 'string') identity.central_url = central_url
272309
if (typeof bootstrap_token_fp === 'string') identity.bootstrap_token_fp = bootstrap_token_fp
273310
if (origin === 'login') identity.origin = origin
@@ -328,18 +365,21 @@ function identityFromPayload(parsed, fallbackGatewayId) {
328365
if (!isPlainObject(parsed)) {
329366
throw new Error('central server response is not an object')
330367
}
331-
const { jwt, expires_at } = /** @type {Record<string, unknown>} */ (parsed)
368+
const { jwt, expires_at, org } = /** @type {Record<string, unknown>} */ (parsed)
332369
if (typeof jwt !== 'string' || jwt.length === 0) {
333370
throw new Error('central server response missing jwt')
334371
}
335372
if (typeof expires_at !== 'number' || !Number.isInteger(expires_at)) {
336373
throw new Error('central server response missing expires_at')
337374
}
375+
if (typeof org !== 'string') {
376+
throw new Error('central server response missing org')
377+
}
338378
const gateway_id = decodeJwtSub(jwt) ?? fallbackGatewayId
339379
if (typeof gateway_id !== 'string' || gateway_id.length === 0) {
340380
throw new Error('central server response missing gateway identity (sub claim)')
341381
}
342-
return { jwt, expires_at, gateway_id }
382+
return { jwt, expires_at, gateway_id, org }
343383
}
344384

345385
/**

0 commit comments

Comments
 (0)