@zlink-systems/framework 0.19.0 → 0.21.0

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.
Files changed (70) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/contracts/Configuration/Builders.d.ts +0 -2
  3. package/dist/contracts/Configuration/Configs.d.ts +1 -0
  4. package/dist/contracts/Configuration/RegistrationTypes.d.ts +1 -2
  5. package/dist/contracts/Locations/Authority.d.ts +1 -7
  6. package/dist/runtime/actors/actor-authority-publication.js +25 -22
  7. package/dist/runtime/actors/actor-creation.js +30 -9
  8. package/dist/runtime/actors/actor-runtime-state.d.ts +3 -0
  9. package/dist/runtime/actors/index.d.ts +3 -0
  10. package/dist/runtime/actors/index.js +14 -3
  11. package/dist/runtime/admission.d.ts +2 -0
  12. package/dist/runtime/admission.js +11 -5
  13. package/dist/runtime/backend/contracts/index.d.ts +4 -1
  14. package/dist/runtime/backend/node/node-mesh-backend-adapter.d.ts +1 -0
  15. package/dist/runtime/backend/node/node-mesh-backend-adapter.js +1 -1
  16. package/dist/runtime/backend/node/node-raw-binding-port.d.ts +2 -1
  17. package/dist/runtime/backend/node/node-raw-binding-port.js +12 -5
  18. package/dist/runtime/backend/node/node-raw-mesh-backend.d.ts +1 -1
  19. package/dist/runtime/backend/node/node-socket-backend-adapter.js +6 -0
  20. package/dist/runtime/channels/channel-socket-options.js +7 -0
  21. package/dist/runtime/channels/channel-socket-registry.js +3 -0
  22. package/dist/runtime/foundation/service-stateful-runtime.d.ts +1 -1
  23. package/dist/runtime/foundation/service-stateful-runtime.js +1 -5
  24. package/dist/runtime/host/actor-placement-coordinator.d.ts +14 -5
  25. package/dist/runtime/host/actor-placement-coordinator.js +159 -47
  26. package/dist/runtime/host/index.d.ts +2 -1
  27. package/dist/runtime/host/index.js +77 -120
  28. package/dist/runtime/host/location-runtime-owner.js +4 -2
  29. package/dist/runtime/host/remote-actor-join-receiver.js +12 -3
  30. package/dist/runtime/locations/creation-operation-id.d.ts +4 -0
  31. package/dist/runtime/locations/creation-operation-id.js +12 -0
  32. package/dist/runtime/locations/in-memory-authority-store.js +8 -22
  33. package/dist/runtime/locations/location-store-repository.js +36 -59
  34. package/dist/runtime/locations/opaque-record-key.d.ts +5 -0
  35. package/dist/runtime/locations/opaque-record-key.js +16 -0
  36. package/dist/runtime/locations/runtime.d.ts +7 -0
  37. package/dist/runtime/locations/runtime.js +130 -42
  38. package/dist/runtime/protocol/service_wire_codec.generated.d.ts +1672 -0
  39. package/dist/runtime/protocol/service_wire_codec.generated.js +9169 -0
  40. package/dist/runtime/spots/spot-node-runtime-manager.js +2 -2
  41. package/package.json +2 -2
  42. package/src/contracts/Configuration/Builders.ts +0 -2
  43. package/src/contracts/Configuration/Configs.ts +1 -0
  44. package/src/contracts/Configuration/RegistrationTypes.ts +1 -2
  45. package/src/contracts/Locations/Authority.ts +1 -8
  46. package/src/runtime/actors/actor-authority-publication.ts +25 -22
  47. package/src/runtime/actors/actor-creation.ts +45 -11
  48. package/src/runtime/actors/actor-runtime-state.ts +4 -0
  49. package/src/runtime/actors/index.ts +32 -4
  50. package/src/runtime/admission.ts +8 -5
  51. package/src/runtime/backend/contracts/index.ts +4 -1
  52. package/src/runtime/backend/node/node-mesh-backend-adapter.ts +2 -1
  53. package/src/runtime/backend/node/node-raw-binding-port.ts +16 -5
  54. package/src/runtime/backend/node/node-raw-mesh-backend.ts +1 -1
  55. package/src/runtime/backend/node/node-socket-backend-adapter.ts +7 -0
  56. package/src/runtime/channels/channel-socket-options.ts +9 -0
  57. package/src/runtime/channels/channel-socket-registry.ts +4 -0
  58. package/src/runtime/foundation/service-stateful-runtime.ts +2 -6
  59. package/src/runtime/host/actor-placement-coordinator.ts +216 -77
  60. package/src/runtime/host/index.ts +95 -135
  61. package/src/runtime/host/location-runtime-owner.ts +4 -2
  62. package/src/runtime/host/remote-actor-join-receiver.ts +15 -3
  63. package/src/runtime/locations/creation-operation-id.ts +11 -0
  64. package/src/runtime/locations/in-memory-authority-store.ts +7 -29
  65. package/src/runtime/locations/location-store-repository.ts +71 -69
  66. package/src/runtime/locations/opaque-record-key.ts +26 -0
  67. package/src/runtime/locations/runtime.ts +154 -60
  68. package/src/runtime/protocol/service_wire_codec.generated.ts +8302 -0
  69. package/src/runtime/protocol/service_wire_pilot_codec.generated.ts +1 -1
  70. package/src/runtime/spots/spot-node-runtime-manager.ts +2 -2
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import type {
3
3
  ZLinkLocationStore,
4
4
  ZLinkLocationPage,
@@ -74,6 +74,11 @@ import {
74
74
  import { ZLinkLocationWriteStatus as WriteStatus } from '../../contracts/Locations';
75
75
  import { ZLinkInMemoryLocationStore } from './in-memory-location-store';
76
76
  import { storeKey } from './in-memory-provider-location-store';
77
+ import {
78
+ creationTerminalPreimage,
79
+ opaqueRecordPreimage,
80
+ routingIdHexSegment
81
+ } from './opaque-record-key';
77
82
  import { decodeAuthorityKey, encodeAuthorityKey } from './authority-key-codec';
78
83
  import { ZLinkAggregateInventoryStore } from './aggregate-inventory-store';
79
84
  import type { RoutingId } from '../../contracts/Common/CoreTypes';
@@ -101,7 +106,6 @@ type StoredAuthoritySnapshot = Omit<
101
106
 
102
107
  interface AuthorityRecord {
103
108
  readonly snapshot: StoredAuthoritySnapshot;
104
- readonly reservationId?: string;
105
109
  readonly terminal?: 'committed' | 'rejected' | 'failed' | 'aborted';
106
110
  readonly aggregate?: AggregateParticipantFenceRecord;
107
111
  readonly visibleStoreVersion?: string;
@@ -1174,7 +1178,6 @@ export class ZLinkLocationStoreRepository extends ZLinkInMemoryLocationStore {
1174
1178
  capacity: cloneCapacity(request.capacity)
1175
1179
  };
1176
1180
  const record: AuthorityRecord = {
1177
- reservationId,
1178
1181
  snapshot: {
1179
1182
  payload: Buffer.from(request.creatingPayload),
1180
1183
  objectGeneration: generation,
@@ -1246,7 +1249,7 @@ export class ZLinkLocationStoreRepository extends ZLinkInMemoryLocationStore {
1246
1249
  const record = decodeAuthorityRecord(current.value.bytes);
1247
1250
  if (
1248
1251
  record.terminal === 'committed'
1249
- && record.reservationId === request.reservationId
1252
+ && record.snapshot.pendingCreation === undefined
1250
1253
  && record.snapshot.allocation.state === 'active'
1251
1254
  ) {
1252
1255
  return {
@@ -1255,7 +1258,7 @@ export class ZLinkLocationStoreRepository extends ZLinkInMemoryLocationStore {
1255
1258
  };
1256
1259
  }
1257
1260
  if (
1258
- record.reservationId !== request.reservationId
1261
+ record.snapshot.pendingCreation?.reservationId !== request.reservationId
1259
1262
  || current.value.version.value !== request.expectedStoreVersion
1260
1263
  || record.snapshot.allocation.state !== 'reserved'
1261
1264
  || !sameCreationTarget(record.snapshot, request.target)
@@ -1277,7 +1280,6 @@ export class ZLinkLocationStoreRepository extends ZLinkInMemoryLocationStore {
1277
1280
  ? emptyCapacityRecord()
1278
1281
  : decodeJson<CapacityRecord>(capacityRead.value.bytes);
1279
1282
  const ready: AuthorityRecord = {
1280
- reservationId: request.reservationId,
1281
1283
  terminal: 'committed',
1282
1284
  snapshot: {
1283
1285
  ...record.snapshot,
@@ -1328,7 +1330,7 @@ export class ZLinkLocationStoreRepository extends ZLinkInMemoryLocationStore {
1328
1330
  if (current.kind === 'missing') return { kind: 'stale' };
1329
1331
  const record = decodeAuthorityRecord(current.value.bytes);
1330
1332
  if (
1331
- record.reservationId !== request.reservationId
1333
+ record.snapshot.pendingCreation?.reservationId !== request.reservationId
1332
1334
  || current.value.version.value !== request.expectedStoreVersion
1333
1335
  || record.snapshot.allocation.state !== 'reserved'
1334
1336
  || !sameCreationTarget(record.snapshot, request.target)
@@ -1404,15 +1406,18 @@ export class ZLinkLocationStoreRepository extends ZLinkInMemoryLocationStore {
1404
1406
  if (existingTerminal.kind === 'found') {
1405
1407
  return {
1406
1408
  kind: 'alreadyCompleted',
1407
- terminal: reviveCreationTerminal(
1408
- decodeJson<ZLinkCreationTerminalRecord>(existingTerminal.value.bytes)
1409
+ terminal: retainedCreationTerminal(
1410
+ terminal.operation,
1411
+ existingTerminal.value.bytes,
1412
+ existingTerminal.value.expiresAt,
1413
+ existingTerminal.value.storeNow
1409
1414
  )
1410
1415
  };
1411
1416
  }
1412
1417
  if (current.kind === 'missing') return { kind: 'stale' };
1413
1418
  const record = decodeAuthorityRecord(current.value.bytes);
1414
1419
  if (
1415
- record.reservationId !== request.reservationId
1420
+ record.snapshot.pendingCreation?.reservationId !== request.reservationId
1416
1421
  || current.value.version.value !== request.expectedStoreVersion
1417
1422
  || record.snapshot.allocation.state !== 'reserved'
1418
1423
  || !sameCreationTarget(record.snapshot, request.target)
@@ -1450,7 +1455,6 @@ export class ZLinkLocationStoreRepository extends ZLinkInMemoryLocationStore {
1450
1455
  kind: 'put' as const,
1451
1456
  key: rowKey,
1452
1457
  bytes: encodeAuthorityRecord({
1453
- reservationId: request.reservationId,
1454
1458
  terminal: 'committed',
1455
1459
  snapshot: {
1456
1460
  ...record.snapshot,
@@ -1493,7 +1497,7 @@ export class ZLinkLocationStoreRepository extends ZLinkInMemoryLocationStore {
1493
1497
  {
1494
1498
  kind: 'put',
1495
1499
  key: terminalRowKey,
1496
- bytes: encodeJson(terminalAtStore),
1500
+ bytes: terminalAtStore.terminalEnvelope,
1497
1501
  retentionMs
1498
1502
  }
1499
1503
  ]
@@ -1513,7 +1517,6 @@ export class ZLinkLocationStoreRepository extends ZLinkInMemoryLocationStore {
1513
1517
  }
1514
1518
  const readyRecord = decodeAuthorityRecord(
1515
1519
  encodeAuthorityRecord({
1516
- reservationId: request.reservationId,
1517
1520
  terminal: 'committed',
1518
1521
  snapshot: {
1519
1522
  ...record.snapshot,
@@ -1541,9 +1544,7 @@ export class ZLinkLocationStoreRepository extends ZLinkInMemoryLocationStore {
1541
1544
  ? { kind: 'missing', storeNow: result.storeNow }
1542
1545
  : {
1543
1546
  kind: 'found',
1544
- record: reviveCreationTerminal(
1545
- decodeJson<ZLinkCreationTerminalRecord>(result.value.bytes)
1546
- )
1547
+ terminalEnvelope: Buffer.from(result.value.bytes)
1547
1548
  };
1548
1549
  }
1549
1550
 
@@ -2152,10 +2153,11 @@ export class ZLinkLocationStoreRepository extends ZLinkInMemoryLocationStore {
2152
2153
  if (result.kind === 'deleted') removed += 1n;
2153
2154
  continue;
2154
2155
  }
2155
- if (record.reservationId === undefined) continue;
2156
+ const reservationId = record.snapshot.pendingCreation?.reservationId;
2157
+ if (reservationId === undefined) continue;
2156
2158
  const aborted = await this.abort({
2157
2159
  key: identity,
2158
- reservationId: record.reservationId,
2160
+ reservationId,
2159
2161
  expectedStoreVersion: current.value.version.value,
2160
2162
  target: {
2161
2163
  meshName: record.snapshot.allocation.descriptor.meshName,
@@ -3141,13 +3143,13 @@ function sha256Hex(bytes: Uint8Array): string {
3141
3143
  // literal NUL bytes -- the same scheme the opaque record store hashes with
3142
3144
  // SHA-256 to derive the Redis key -- so a runtime in one language can read a
3143
3145
  // record another language wrote. Not used for framework-internal-only rows
3144
- // (aggregate/capacity/creation-terminal/spot/actor/route), which stay on the
3145
- // legacy PREFIX scheme since they aren't part of the public opaque contract.
3146
+ // (aggregate/capacity/spot/actor/route), which stay on the legacy PREFIX
3147
+ // scheme since they aren't part of the public opaque contract.
3146
3148
  function ownerKey(ownerId: string) {
3147
- return storeKey(`owner-lease\0${requireNoNul(ownerId, 'OwnerId')}`);
3149
+ return storeKey(opaqueRecordPreimage('owner-lease', requireNoNul(ownerId, 'OwnerId')));
3148
3150
  }
3149
3151
 
3150
- const AUTHORITY_PREIMAGE_PREFIX = 'authority\0';
3152
+ const AUTHORITY_PREIMAGE_PREFIX = `${opaqueRecordPreimage('authority')}\0`;
3151
3153
 
3152
3154
  function authorityKey(value: string) {
3153
3155
  return storeKey(authorityPreimage(value));
@@ -3158,7 +3160,11 @@ function authorityPreimage(value: string): string {
3158
3160
  // §2.3: Entry|User|Instance Spot kinds share one segment ("spot") -- one
3159
3161
  // Id has exactly one authority row regardless of spot kind.
3160
3162
  const segment = decoded.kind === 'actor' ? 'actor' : 'spot';
3161
- return `${AUTHORITY_PREIMAGE_PREFIX}${segment}\0${requireNoNul(decoded.globalId, 'Authority Id')}`;
3163
+ return opaqueRecordPreimage(
3164
+ 'authority',
3165
+ segment,
3166
+ requireNoNul(decoded.globalId, 'Authority Id')
3167
+ );
3162
3168
  }
3163
3169
 
3164
3170
  // Reverses authorityPreimage(): recovers the framework-internal zla1-encoded
@@ -3235,42 +3241,49 @@ function capacityKey(meshName: string, nodeRid: string) {
3235
3241
 
3236
3242
  function creationTerminalKey(operation: ZLinkCreationOperationIdentity) {
3237
3243
  validateCreationOperation(operation);
3238
- return storeKey(`${PREFIX}creation-terminal:${[
3239
- String(operation.sourceNodeRid),
3240
- operation.sourceNodeGeneration.toString(),
3241
- operation.operationId.high.toString(16).padStart(16, '0'),
3242
- operation.operationId.low.toString(16).padStart(16, '0')
3243
- ].map(encodeURIComponent).join(':')}`);
3244
+ return storeKey(creationTerminalPreimage(operation));
3244
3245
  }
3245
3246
 
3246
3247
  function meshPrefix(meshName: string): string {
3247
- return `mesh-node\0${requireNoNul(meshName, 'MeshName')}\0`;
3248
+ return `${opaqueRecordPreimage('mesh-node', requireNoNul(meshName, 'MeshName'))}\0`;
3248
3249
  }
3249
3250
 
3250
3251
  function meshKey(meshName: string, nodeRid: RoutingId) {
3251
- return storeKey(`${meshPrefix(meshName)}${routingIdHexSegment(nodeRid)}`);
3252
+ return storeKey(opaqueRecordPreimage(
3253
+ 'mesh-node',
3254
+ requireNoNul(meshName, 'MeshName'),
3255
+ routingIdHexSegment(nodeRid)
3256
+ ));
3252
3257
  }
3253
3258
 
3254
3259
  function clientServerPrefix(channelName: string): string {
3255
- return `client-server\0${requireNoNul(channelName, 'ChannelName')}\0`;
3260
+ return `${opaqueRecordPreimage(
3261
+ 'client-server',
3262
+ requireNoNul(channelName, 'ChannelName')
3263
+ )}\0`;
3256
3264
  }
3257
3265
 
3258
3266
  function clientServerKey(channelName: string, serverRid: RoutingId) {
3259
- return storeKey(`${clientServerPrefix(channelName)}${routingIdHexSegment(serverRid)}`);
3260
- }
3261
-
3262
- // {hex(RoutingId)}: lowercase hex of the RoutingId's raw bytes
3263
- // (21-location-runtime.md#2.4).
3264
- function routingIdHexSegment(rid: RoutingId): string {
3265
- return encodeRoutingIdStorageHex(rid).toLowerCase();
3267
+ return storeKey(opaqueRecordPreimage(
3268
+ 'client-server',
3269
+ requireNoNul(channelName, 'ChannelName'),
3270
+ routingIdHexSegment(serverRid)
3271
+ ));
3266
3272
  }
3267
3273
 
3268
3274
  function fanoutPrefix(channelName: string): string {
3269
- return `fanout-publisher\0${requireNoNul(channelName, 'ChannelName')}\0`;
3275
+ return `${opaqueRecordPreimage(
3276
+ 'fanout-publisher',
3277
+ requireNoNul(channelName, 'ChannelName')
3278
+ )}\0`;
3270
3279
  }
3271
3280
 
3272
3281
  function fanoutKey(channelName: string, publisherRid: RoutingId) {
3273
- return storeKey(`${fanoutPrefix(channelName)}${routingIdHexSegment(publisherRid)}`);
3282
+ return storeKey(opaqueRecordPreimage(
3283
+ 'fanout-publisher',
3284
+ requireNoNul(channelName, 'ChannelName'),
3285
+ routingIdHexSegment(publisherRid)
3286
+ ));
3274
3287
  }
3275
3288
 
3276
3289
  function spotKey(meshName: string, spotId: string) {
@@ -3567,7 +3580,6 @@ function encodeAuthorityRecord(record: AuthorityRecord): Uint8Array {
3567
3580
  // These fields gate Node-only creation/aggregate recovery. They are absent
3568
3581
  // from normal authority values (including the golden vector), while the
3569
3582
  // interoperable envelope above remains byte-canonical.
3570
- if (record.reservationId !== undefined) envelope.reservationId = record.reservationId;
3571
3583
  if (record.terminal !== undefined) envelope.terminal = record.terminal;
3572
3584
  if (record.aggregate !== undefined) envelope.aggregate = canonicalize(record.aggregate);
3573
3585
  if (record.visibleStoreVersion !== undefined) envelope.visibleStoreVersion = record.visibleStoreVersion;
@@ -3583,7 +3595,6 @@ function decodeAuthorityRecord(bytes: Uint8Array): AuthorityRecord {
3583
3595
  const pending = value.pendingCreation as Record<string, unknown> | null;
3584
3596
  const objectKind = runtimePlacementObjectKind(String(allocation.objectKind));
3585
3597
  return {
3586
- reservationId: typeof value.reservationId === 'string' ? value.reservationId : undefined,
3587
3598
  terminal: value.terminal as AuthorityRecord['terminal'],
3588
3599
  aggregate: value.aggregate === undefined
3589
3600
  ? undefined
@@ -3682,17 +3693,8 @@ function createTerminalRecord(
3682
3693
  ): ZLinkCreationTerminalRecord {
3683
3694
  const publication = request.completion.terminal;
3684
3695
  validateCreationOperation(publication.operation);
3685
- if (
3686
- publication.terminalEnvelope.byteLength > MAX_CREATION_TERMINAL_BYTES
3687
- || publication.terminalEnvelopeSha256.byteLength !== 32
3688
- ) {
3689
- throw new RangeError(
3690
- 'Creation terminal envelope must not exceed 1 MiB and requires a SHA-256 digest.'
3691
- );
3692
- }
3693
- const actualSha = createHash('sha256').update(publication.terminalEnvelope).digest();
3694
- if (!timingSafeEqual(actualSha, Buffer.from(publication.terminalEnvelopeSha256))) {
3695
- throw new TypeError('Creation terminal envelope SHA-256 does not match its bytes.');
3696
+ if (publication.terminalEnvelope.byteLength > MAX_CREATION_TERMINAL_BYTES) {
3697
+ throw new RangeError('Creation terminal envelope must not exceed 1 MiB.');
3696
3698
  }
3697
3699
  const deadlineMs = publication.operationDeadline.getTime();
3698
3700
  const expiresAtMs = deadlineMs + CREATION_TERMINAL_RETENTION_MS;
@@ -3709,35 +3711,35 @@ function createTerminalRecord(
3709
3711
  validatePayloadSize(request.completion.readyPayload, 'Actor ready payload');
3710
3712
  }
3711
3713
  return {
3712
- state: request.completion.kind,
3713
3714
  operation: {
3714
3715
  sourceNodeRid: publication.operation.sourceNodeRid,
3715
3716
  sourceNodeGeneration: publication.operation.sourceNodeGeneration,
3716
3717
  operationId: { ...publication.operation.operationId }
3717
3718
  },
3718
- reservationId: requireText(request.reservationId, 'creation reservation ID'),
3719
- objectKind: request.key.kind,
3720
3719
  terminalEnvelope: Buffer.from(publication.terminalEnvelope),
3721
- terminalEnvelopeSha256: Buffer.from(publication.terminalEnvelopeSha256),
3722
3720
  expiresAt: new Date(expiresAtMs),
3723
3721
  storeNow: new Date(now)
3724
3722
  };
3725
3723
  }
3726
3724
 
3727
- function reviveCreationTerminal(
3728
- record: ZLinkCreationTerminalRecord
3725
+ function retainedCreationTerminal(
3726
+ operation: ZLinkCreationOperationIdentity,
3727
+ terminalEnvelope: Uint8Array,
3728
+ expiresAt: Date | undefined,
3729
+ storeNow: Date
3729
3730
  ): ZLinkCreationTerminalRecord {
3731
+ if (expiresAt === undefined) {
3732
+ throw new Error('Retained creation terminal is missing its expiration.');
3733
+ }
3730
3734
  return {
3731
- ...record,
3732
3735
  operation: {
3733
- ...record.operation,
3734
- sourceNodeRid: String(record.operation.sourceNodeRid),
3735
- operationId: { ...record.operation.operationId }
3736
+ sourceNodeRid: operation.sourceNodeRid,
3737
+ sourceNodeGeneration: operation.sourceNodeGeneration,
3738
+ operationId: { ...operation.operationId }
3736
3739
  },
3737
- terminalEnvelope: Buffer.from(record.terminalEnvelope),
3738
- terminalEnvelopeSha256: Buffer.from(record.terminalEnvelopeSha256),
3739
- expiresAt: reviveDate(record.expiresAt),
3740
- storeNow: reviveDate(record.storeNow)
3740
+ terminalEnvelope: Buffer.from(terminalEnvelope),
3741
+ expiresAt: new Date(expiresAt),
3742
+ storeNow: new Date(storeNow)
3741
3743
  };
3742
3744
  }
3743
3745
 
@@ -3751,7 +3753,7 @@ function validateCreationOperation(operation: ZLinkCreationOperationIdentity): v
3751
3753
  }
3752
3754
  if (
3753
3755
  operation.sourceNodeGeneration < 1n
3754
- || operation.sourceNodeGeneration > MAX_GENERATION
3756
+ || operation.sourceNodeGeneration > MAX_U64
3755
3757
  || operation.operationId.high < 0n
3756
3758
  || operation.operationId.high > MAX_U64
3757
3759
  || operation.operationId.low < 0n
@@ -0,0 +1,26 @@
1
+ import type { RoutingId } from '../../contracts/Common/CoreTypes';
2
+ import type { ZLinkCreationOperationIdentity } from './internal-location-contracts';
3
+ import { encodeRoutingIdStorageHex } from '../routing-id';
4
+
5
+ export function opaqueRecordPreimage(
6
+ record: string,
7
+ ...segments: readonly string[]
8
+ ): string {
9
+ return [record, ...segments].join('\0');
10
+ }
11
+
12
+ export function creationTerminalPreimage(
13
+ operation: ZLinkCreationOperationIdentity
14
+ ): string {
15
+ return opaqueRecordPreimage(
16
+ 'creation-terminal',
17
+ routingIdHexSegment(operation.sourceNodeRid),
18
+ operation.sourceNodeGeneration.toString(),
19
+ operation.operationId.high.toString(16).padStart(16, '0')
20
+ + operation.operationId.low.toString(16).padStart(16, '0')
21
+ );
22
+ }
23
+
24
+ export function routingIdHexSegment(routingId: RoutingId): string {
25
+ return encodeRoutingIdStorageHex(routingId).toLowerCase();
26
+ }
@@ -132,6 +132,13 @@ export class ZLinkOwnerCleanupError extends Error {
132
132
  }
133
133
  }
134
134
 
135
+ export class ZLinkOwnerLeaseClaimError extends Error {
136
+ constructor(readonly kind: 'conflict' | 'generationExhausted') {
137
+ super(`Owner lease claim failed with '${kind}'.`);
138
+ this.name = 'ZLinkOwnerLeaseClaimError';
139
+ }
140
+ }
141
+
135
142
  export class ZLinkLocationRuntime implements ZLinkLocationRuntimeQuery {
136
143
  readonly ownerId: string;
137
144
  private readonly options: Required<ZLinkLocationOptionOverrides>;
@@ -163,9 +170,9 @@ export class ZLinkLocationRuntime implements ZLinkLocationRuntimeQuery {
163
170
  lastError?: string;
164
171
 
165
172
  get ownerLeaseUsable(): boolean {
166
- return this.ownerLeaseHealthy
167
- && (this.ownerLeaseDeadlineMs === undefined
168
- || this.monotonicNowMs() < this.ownerLeaseDeadlineMs);
173
+ return this.ownerToken !== undefined
174
+ && this.ownerLeaseDeadlineMs !== undefined
175
+ && this.monotonicNowMs() < this.ownerLeaseDeadlineMs;
169
176
  }
170
177
 
171
178
  constructor(runtimeOptions: ZLinkLocationRuntimeOptions) {
@@ -200,6 +207,11 @@ export class ZLinkLocationRuntime implements ZLinkLocationRuntimeQuery {
200
207
  return this.ownerToken;
201
208
  }
202
209
 
210
+ closeOwnerLeaseAdmission(error: unknown): void {
211
+ this.ownerLeaseDeadlineMs = undefined;
212
+ this.recordFailure(errorMessage(error), 'owner_lease_recovery');
213
+ }
214
+
203
215
  async reclaimOwnerAuthorities(signal?: AbortSignal): Promise<void> {
204
216
  const owner = this.ownerToken;
205
217
  if (owner === undefined) {
@@ -303,48 +315,19 @@ export class ZLinkLocationRuntime implements ZLinkLocationRuntimeQuery {
303
315
  this.started = true;
304
316
  this.ownerCleanupComplete = false;
305
317
  this.nodeRidValue = nodeRid;
306
- const claimStartedAtMs = this.monotonicNowMs();
307
- let claim: Awaited<ReturnType<ZLinkOwnerLeaseStore['claimOwnerLease']>>;
308
318
  try {
309
- claim = await withTimeout(
310
- (claimSignal) => this.stores.ownerLeaseStore.claimOwnerLease(
311
- this.ownerId,
312
- this.options.ownerLeaseTtlMs,
313
- claimSignal
314
- ),
315
- this.options.ownerLeaseRenewTimeoutMs,
316
- signal
317
- );
318
- } catch (error) {
319
- this.ownerLeaseHealthy = false;
320
- this.ownerLeaseDeadlineMs = undefined;
321
- if (signal?.aborted === true) throw error;
322
- this.recordFailure(errorMessage(error), 'owner_lease_claim');
323
- // A Location host can still serve transport work while the Store is
324
- // unavailable. Keep the runtime in degraded mode so the heartbeat can
325
- // claim a lease after recovery; no descriptor or stateful authority is
326
- // published until that claim succeeds.
327
- this.nextLeaseRenewAtMs = this.monotonicNowMs();
319
+ const claimed = await this.renewOwnerLeaseOnce(signal);
320
+ if (claimed) {
321
+ this.scheduleHeartbeat();
322
+ return;
323
+ }
324
+ this.nextLeaseRenewAtMs = this.monotonicNowMs() + this.options.ownerLeaseRenewIntervalMs;
328
325
  this.scheduleHeartbeat();
329
- return;
330
- }
331
- const claimCompletedAtMs = this.monotonicNowMs();
332
- if (claim.kind !== 'claimed') {
326
+ } catch (error) {
333
327
  this.started = false;
334
328
  this.ownerCleanupComplete = true;
335
- throw new Error(`Owner lease claim failed with '${claim.kind}'.`);
329
+ throw error;
336
330
  }
337
- this.ownerToken = claim.token;
338
- this.ownerLeaseHealthy = true;
339
- this.ownerLeaseRenewedAt = claim.storeNow;
340
- this.setOwnerLeaseDeadline(
341
- claimStartedAtMs,
342
- claimCompletedAtMs,
343
- claim.leaseExpiresAt,
344
- claim.storeNow
345
- );
346
- this.nextLeaseRenewAtMs = this.monotonicNowMs() + this.options.ownerLeaseRenewIntervalMs;
347
- this.scheduleHeartbeat();
348
331
  }
349
332
 
350
333
  async stop(signal?: AbortSignal): Promise<void> {
@@ -395,6 +378,7 @@ export class ZLinkLocationRuntime implements ZLinkLocationRuntimeQuery {
395
378
  return await this.claimFreshOwnerLease(signal);
396
379
  }
397
380
 
381
+ const wasOwnerLeaseUsable = this.ownerLeaseUsable;
398
382
  try {
399
383
  const renewStartedAtMs = this.monotonicNowMs();
400
384
  const result = await withTimeout(
@@ -431,7 +415,9 @@ export class ZLinkLocationRuntime implements ZLinkLocationRuntimeQuery {
431
415
  result.storeNow
432
416
  );
433
417
  this.lastError = undefined;
434
- for (const handler of this.ownerLeaseRenewedHandlers) handler(result);
418
+ if (!wasOwnerLeaseUsable) {
419
+ for (const handler of this.ownerLeaseRenewedHandlers) handler(result);
420
+ }
435
421
  return true;
436
422
  } catch (error) {
437
423
  if (signal?.aborted === true) throw error;
@@ -443,26 +429,39 @@ export class ZLinkLocationRuntime implements ZLinkLocationRuntimeQuery {
443
429
  }
444
430
 
445
431
  private async claimFreshOwnerLease(signal?: AbortSignal): Promise<boolean> {
432
+ const claimStartedAtMs = this.monotonicNowMs();
433
+ const timeoutDeadline = performance.now() + this.options.ownerLeaseRenewTimeoutMs;
434
+ let claim: Awaited<ReturnType<ZLinkOwnerLeaseStore['claimOwnerLease']>>;
435
+ try {
436
+ claim = await this.claimOwnerLeaseWithConfirmation(timeoutDeadline, signal);
437
+ } catch (error) {
438
+ if (signal?.aborted === true) throw error;
439
+ if (error instanceof ZLinkOwnerLeaseClaimError) throw error;
440
+ this.recordOwnerLeaseRenewFailure();
441
+ this.recordFailure(errorMessage(error), 'owner_lease_claim');
442
+ return false;
443
+ }
444
+ const claimCompletedAtMs = this.monotonicNowMs();
445
+ if (claim.kind !== 'claimed') {
446
+ const error = new ZLinkOwnerLeaseClaimError(claim.kind);
447
+ this.recordFailure(error.message, 'owner_lease_claim');
448
+ throw error;
449
+ }
446
450
  try {
447
- const claimStartedAtMs = this.monotonicNowMs();
448
- const claim = await withTimeout(
449
- (claimSignal) => this.stores.ownerLeaseStore.claimOwnerLease(
450
- this.ownerId,
451
- this.options.ownerLeaseTtlMs,
452
- claimSignal
453
- ),
454
- this.options.ownerLeaseRenewTimeoutMs,
455
- signal
456
- );
457
- const claimCompletedAtMs = this.monotonicNowMs();
458
- if (claim.kind !== 'claimed') {
459
- this.recordFailure(`Owner lease claim failed with '${claim.kind}'.`, 'owner_lease_claim');
460
- return false;
461
- }
462
451
  if (!this.started) {
463
452
  // stop() may have completed while the Store claim was in flight. Do
464
453
  // not install a token into a stopped runtime or leave an orphan lease.
465
- await this.stores.ownerLeaseStore.releaseOwnerLease(claim.token).catch(() => undefined);
454
+ try {
455
+ await withTimeout(
456
+ releaseSignal => this.stores.ownerLeaseStore.releaseOwnerLease(
457
+ claim.token,
458
+ releaseSignal
459
+ ),
460
+ remainingTimeoutMs(timeoutDeadline)
461
+ );
462
+ } catch (error) {
463
+ this.recordFailure(errorMessage(error), 'owner_lease_release');
464
+ }
466
465
  return false;
467
466
  }
468
467
  this.lastOwnerToken = this.ownerToken;
@@ -487,12 +486,83 @@ export class ZLinkLocationRuntime implements ZLinkLocationRuntimeQuery {
487
486
  return true;
488
487
  } catch (error) {
489
488
  if (signal?.aborted === true) throw error;
490
- this.recordOwnerLeaseRenewFailure();
491
489
  this.recordFailure(errorMessage(error), 'owner_lease_claim');
492
490
  return false;
493
491
  }
494
492
  }
495
493
 
494
+ private async claimOwnerLeaseWithConfirmation(
495
+ timeoutDeadline: number,
496
+ signal?: AbortSignal
497
+ ): Promise<Awaited<ReturnType<ZLinkOwnerLeaseStore['claimOwnerLease']>>> {
498
+ signal?.throwIfAborted();
499
+ let claim: Awaited<ReturnType<ZLinkOwnerLeaseStore['claimOwnerLease']>>;
500
+ try {
501
+ claim = await withTimeout(
502
+ (claimSignal) => this.stores.ownerLeaseStore.claimOwnerLease(
503
+ this.ownerId,
504
+ this.options.ownerLeaseTtlMs,
505
+ claimSignal
506
+ ),
507
+ remainingTimeoutMs(timeoutDeadline),
508
+ signal
509
+ );
510
+ } catch (error) {
511
+ if (error instanceof OwnerLeaseRenewTimeoutError) throw error;
512
+ const confirmed = await this.confirmOwnerLease(error, timeoutDeadline, signal);
513
+ if (confirmed !== undefined) return confirmed;
514
+ throw error;
515
+ }
516
+ if (claim.kind !== 'conflict') return claim;
517
+ const conflict = new ZLinkOwnerLeaseClaimError(claim.kind);
518
+ return await this.confirmOwnerLease(conflict, timeoutDeadline, signal) ?? claim;
519
+ }
520
+
521
+ private async confirmOwnerLease(
522
+ originalError: unknown,
523
+ timeoutDeadline: number,
524
+ signal?: AbortSignal
525
+ ): Promise<Extract<Awaited<ReturnType<ZLinkOwnerLeaseStore['claimOwnerLease']>>, { kind: 'claimed' }> | undefined> {
526
+ const confirmationTimeoutMs = remainingTimeoutMs(timeoutDeadline);
527
+ if (confirmationTimeoutMs <= 0) throw originalError;
528
+ let confirmed: Awaited<ReturnType<ZLinkOwnerLeaseStore['readOwnerLease']>>;
529
+ try {
530
+ confirmed = await withTimeout(
531
+ readSignal => this.stores.ownerLeaseStore.readOwnerLease(this.ownerId, readSignal),
532
+ confirmationTimeoutMs
533
+ );
534
+ } catch (confirmationError) {
535
+ this.recordFailure(
536
+ errorMessage(confirmationError),
537
+ 'owner_lease_claim_confirmation'
538
+ );
539
+ throw originalError;
540
+ }
541
+ if (confirmed.kind !== 'found' || confirmed.token.ownerId !== this.ownerId) {
542
+ throw originalError;
543
+ }
544
+ if (signal?.aborted === true) {
545
+ try {
546
+ await withTimeout(
547
+ releaseSignal => this.stores.ownerLeaseStore.releaseOwnerLease(
548
+ confirmed.token,
549
+ releaseSignal
550
+ ),
551
+ remainingTimeoutMs(timeoutDeadline)
552
+ );
553
+ } catch (releaseError) {
554
+ this.recordFailure(errorMessage(releaseError), 'owner_lease_release');
555
+ }
556
+ throw originalError;
557
+ }
558
+ return {
559
+ kind: 'claimed',
560
+ token: confirmed.token,
561
+ leaseExpiresAt: confirmed.leaseExpiresAt,
562
+ storeNow: confirmed.storeNow
563
+ };
564
+ }
565
+
496
566
  private setOwnerLeaseDeadline(
497
567
  requestStartedAtMs: number,
498
568
  requestCompletedAtMs: number,
@@ -1106,21 +1176,45 @@ async function withTimeout<T>(
1106
1176
  ? timeoutController.signal
1107
1177
  : AbortSignal.any([signal, timeoutController.signal]);
1108
1178
  let timeout: ReturnType<typeof setTimeout> | undefined;
1179
+ let abort: (() => void) | undefined;
1109
1180
  const deadline = new Promise<never>((_resolve, reject) => {
1110
1181
  timeout = setTimeout(() => {
1111
- const error = new Error(`Owner lease renewal exceeded ${timeoutMs}ms.`);
1182
+ const error = new OwnerLeaseRenewTimeoutError(timeoutMs);
1112
1183
  timeoutController.abort(error);
1113
1184
  reject(error);
1114
1185
  }, timeoutMs);
1115
1186
  timeout.unref();
1116
1187
  });
1188
+ const cancellation = signal === undefined
1189
+ ? undefined
1190
+ : new Promise<never>((_resolve, reject) => {
1191
+ abort = () => reject(signal.reason);
1192
+ signal.addEventListener('abort', abort, { once: true });
1193
+ });
1117
1194
  try {
1118
- return await Promise.race([action(operationSignal), deadline]);
1195
+ signal?.throwIfAborted();
1196
+ return await Promise.race(
1197
+ cancellation === undefined
1198
+ ? [action(operationSignal), deadline]
1199
+ : [action(operationSignal), deadline, cancellation]
1200
+ );
1119
1201
  } finally {
1120
1202
  if (timeout !== undefined) clearTimeout(timeout);
1203
+ if (abort !== undefined) signal?.removeEventListener('abort', abort);
1121
1204
  }
1122
1205
  }
1123
1206
 
1207
+ class OwnerLeaseRenewTimeoutError extends Error {
1208
+ constructor(timeoutMs: number) {
1209
+ super(`Owner lease renewal exceeded ${timeoutMs}ms.`);
1210
+ this.name = 'OwnerLeaseRenewTimeoutError';
1211
+ }
1212
+ }
1213
+
1214
+ function remainingTimeoutMs(timeoutDeadline: number): number {
1215
+ return Math.max(0, timeoutDeadline - performance.now());
1216
+ }
1217
+
1124
1218
  function errorMessage(error: unknown): string {
1125
1219
  return error instanceof Error ? error.message : String(error);
1126
1220
  }