@serve.zone/interfaces 32.46.0 → 32.48.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.
package/readme.md CHANGED
@@ -363,9 +363,15 @@ suffix. Fully qualified names remain unambiguous across networks. Network policy
363
363
  permits member-to-member connectivity and explicitly configures or denies external
364
364
  DNS forwarding. Creating an alias does not publish public DNS, ingress or ports.
365
365
 
366
- `data.composeServicePrivateNetworkDnsPolicy(membership, networks)` derives the
366
+ `data.composeServicePrivateNetworkDnsPolicy(membership, networks, resolvers)` derives the
367
367
  service's network suffixes, the single selected search suffix, and the intersection
368
- of external forwarding permissions. Supply every attached network exactly once,
368
+ of external forwarding permissions. `resolvers` is the complete resolver list of the
369
+ protected authority the service is projected under. A service attached to no private
370
+ network forwards any public name to the first four of those resolvers in address and
371
+ port order; when the authority declares no resolver it has no external resolution
372
+ (`externalDns: null`, so its node answers REFUSED for every name). An attached service
373
+ is decided by its networks alone: a network denying external DNS keeps denying its
374
+ members whatever the authority declares. Supply every attached network exactly once,
369
375
  with active state and matching canonical organization/DNS keys. Missing, extra,
370
376
  inactive or inconsistent network definitions are rejected. Forwarding requires
371
377
  every attachment to permit both the queried public name and the same exact
@@ -1553,6 +1559,62 @@ session, that the assignment is this node's and carries the batch's attempt, and
1553
1559
  joined length against the policy. `bindReportRuntimeAssignmentLogsResponse(response, request)`
1554
1560
  binds the acknowledgement to the batch.
1555
1561
 
1562
+ **Reading kept logs.** An operator reads what Cloudly keeps through two service requests, which
1563
+ take `{ identity, serviceId }` and are authorized like every other read of that service. Cloudly
1564
+ keeps an attempt's logs from the moment it issues the attempt, so an attempt that failed before
1565
+ writing a line is listed with nothing held, and an attempt whose slot was replaced or released stays
1566
+ readable until its logs expire.
1567
+
1568
+ - `requests.service.getServiceRuntimeLogAttempts { identity, serviceId, cursor, limit }` answers an
1569
+ `IRuntimeAssignmentLogAttemptPage { attempts, nextCursor }`: the service's attempts, newest issued
1570
+ first and ties in `assignmentId` order. An `IRuntimeAssignmentLogAttemptView` states `serviceId`,
1571
+ `replicaId`, `nodeId`, `assignmentId`, `attemptId`, `issuedAt`, `lastAcceptedAt` (null until a
1572
+ batch was accepted), `expiresAt` (when the attempt's logs are dropped whole: the latest of
1573
+ `issuedAt`, `lastAcceptedAt` and the time the attempt's slot released it, plus the config's
1574
+ `logs.retentionMs`; null while the slot still holds the attempt, which schedules no expiry however
1575
+ quiet it is), `captures` and `capturesEnded` (captures with a batch, and
1576
+ those whose final batch arrived), `keptBytes` (UTF-8 bytes of `text` held) and `truncatedBatches`.
1577
+ `limit` runs from 1 to `runtimeAssignmentLogReadContract.maximumAttemptPageSize` (256).
1578
+ - `requests.service.getRuntimeAssignmentLogs { identity, serviceId, assignmentId, cursor, limit }`
1579
+ answers an `IRuntimeAssignmentLogPage { attempt, records, nextCursor }`: one attempt's records in
1580
+ delivery order, captures in the order the first batch of each was accepted and each capture by
1581
+ sequence. `limit` counts records, from 1 to `maximumPageRecords` (256). A page also ends before
1582
+ its records would hold more than `maximumPageEntries` (4 096, four full batches) entries or its
1583
+ canonical JSON would cross `maximumPageCanonicalBytes` (2 MiB), so it may hold fewer than `limit`
1584
+ while `nextCursor` names more. One batch always fits: its record is smaller than the report that
1585
+ carried it.
1586
+
1587
+ A `TRuntimeAssignmentLogRecord` is one of three, so nothing is ever dropped silently:
1588
+
1589
+ - `batch { captureId, sequence, acceptedAt, final, entries }`: a batch as the node sent it, its
1590
+ lines and loss entries unchanged;
1591
+ - `gap { captureId, fromSequence, toSequence }`: sequence numbers whose batches Cloudly did not
1592
+ receive or no longer holds, its own loss: numbers that never arrived and that the node did not
1593
+ account for, and accepted batches whose payload Cloudly has lost. Numbers the node skipped are not
1594
+ a gap: the batch after them opens with the `not-kept` loss that counts them;
1595
+ - `truncated { captureId, throughSequence, reasons, batches, bytes, entries }`: every batch Cloudly
1596
+ held of the capture up to `throughSequence` and has since dropped, oldest first, with exact counts.
1597
+ `reasons` (`TRuntimeAssignmentLogTruncationReason`, duplicate-free, in the canonical order
1598
+ `batch-cap`, `byte-cap`, `retention`) are `batch-cap`, Cloudly's bound on how many batches it
1599
+ keeps of one attempt, `byte-cap`, its bound on the text it keeps of one attempt, and `retention`,
1600
+ the config's `logs.retentionMs`. A node delivers about once a second, so a workload that writes
1601
+ now and then sends many small batches, which the batch bound holds where the text bound would
1602
+ not. Gaps inside the range are no longer stated.
1603
+
1604
+ `snapshotRuntimeAssignmentLogPage` enforces that order: a capture's records are contiguous and never
1605
+ return, a truncation only opens its capture, a gap starts at the number after the record before it,
1606
+ a gap never directly follows another gap (the controller states adjacent losses, received or not,
1607
+ as one gap, across pages too), a batch continues at the next number or, past numbers the node
1608
+ skipped, opens with a `not-kept` loss, and nothing follows a final batch. A page may resume inside a
1609
+ capture, so a capture's first record on a page is not judged against what came before it. A `nextCursor` is
1610
+ an opaque identifier, null on the last page, and never follows an empty page.
1611
+ `snapshotRuntimeAssignmentLogAttemptView` and `snapshotRuntimeAssignmentLogAttemptPage` read the
1612
+ attempt side. `bindRuntimeAssignmentLogAttemptPage(page, serviceId, limit)` and
1613
+ `bindRuntimeAssignmentLogPage(page, serviceId, assignmentId, limit)` match an answer to its read. A
1614
+ refused read carries `IRuntimeAssignmentLogReadErrorData { code, retryable: false }`, where
1615
+ `TRuntimeAssignmentLogReadRefusal` is `attempt-not-found` (no logs of that attempt for that
1616
+ service), `cursor-invalid` or `limit-invalid`.
1617
+
1556
1618
  **Stats.** `IReq_Controller_Pallet_ReadRuntimeAssignmentStats` (`readRuntimeAssignmentStats`)
1557
1619
  reads one sample of one attempt: `{ session, assignment }` names the exact current revision, and
1558
1620
  the answer is `{ status: 'sampled', assignment, sample }` or `{ status: 'not-running', assignment,
@@ -2009,6 +2071,9 @@ through every slot on a cluster's nodes; `limit` runs from 1 to
2009
2071
  `serviceRuntimeContract.maximumAssignmentPageSize` (256), and Cloudly refuses any other.
2010
2072
  `requests.service.pushServiceRuntimeChanged { serviceId }` tells admin UIs that a service's spec,
2011
2073
  status or assignments changed; it carries no state, so a late push never overwrites a newer read.
2074
+ A slot's replaced and released attempts leave these views; their kept workload logs stay readable
2075
+ through `getServiceRuntimeLogAttempts` and `getRuntimeAssignmentLogs` (see
2076
+ [Runtime Workload Logs, Stats, Exec And Volume Archives](#runtime-workload-logs-stats-exec-and-volume-archives)).
2012
2077
 
2013
2078
  ### Node Retirement
2014
2079
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.46.0',
6
+ version: '32.48.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -666,7 +666,8 @@ export const validateRuntimeNetworkProjection = async (value: unknown): Promise<
666
666
  }
667
667
  const members = new Map(projection.memberships.map(member => [ownerKey(member), member])), aliases = new Set<string>();
668
668
  for (const member of projection.memberships) {
669
- composeServicePrivateNetworkDnsPolicy(member, member.attachments.map(attachment => networks.get(attachment.networkId)));
669
+ composeServicePrivateNetworkDnsPolicy(member, member.attachments.map(attachment => networks.get(attachment.networkId)),
670
+ current.resolvers);
670
671
  for (const attachment of member.attachments) for (const alias of attachment.aliases) {
671
672
  const name = buildPrivateNetworkFqdn(networks.get(attachment.networkId), alias);
672
673
  requireNetwork(!aliases.has(name)); aliases.add(name);
@@ -756,7 +757,8 @@ export const composeRuntimeNetworkProjectionDnsViews = async (
756
757
  const members = new Map(projection.memberships.map(member => [ownerKey(member), member]));
757
758
  return local.filter(endpoint => selected === null || selected.some(source => networkEqual(networkReferenceOf(endpoint.lease), source))).map(endpoint => {
758
759
  const member = members.get(ownerKey(endpoint.lease))!;
759
- const policy = composeServicePrivateNetworkDnsPolicy(member, member.attachments.map(attachment => networks.get(attachment.networkId)));
760
+ const policy = composeServicePrivateNetworkDnsPolicy(member,
761
+ member.attachments.map(attachment => networks.get(attachment.networkId)), projection.protectedAuthority.resolvers);
760
762
  const records: IRuntimeNetworkDnsView['records'] = [];
761
763
  for (const target of projection.memberships) {
762
764
  if (target.organizationId !== member.organizationId) continue;
@@ -2,6 +2,7 @@ import {
2
2
  readDataElements, readDataProperty, readExactDataRecord,
3
3
  } from '../private/dataproperties.js';
4
4
  import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
5
+ import { runtimeNetworkAuthorityContract } from './networkauthority.js';
5
6
 
6
7
  /** Cloudly owns these definitions. Membership never follows node placement. */
7
8
  export interface IPrivateNetworkPolicy {
@@ -126,6 +127,13 @@ const ipv4 = (value: unknown): string => {
126
127
  requireValue(isPlausibleUnicastIpv4(value));
127
128
  return value;
128
129
  };
130
+ const endpoint = (value: Record<string, unknown>): { address: string; port: number } => {
131
+ const port = counter(value.port);
132
+ requireValue(port <= 65535);
133
+ return { address: ipv4(value.address), port };
134
+ };
135
+ const byEndpoint = (left: { address: string; port: number }, right: { address: string; port: number }): number =>
136
+ left.address < right.address ? -1 : left.address > right.address ? 1 : left.port - right.port;
129
137
  const publicSuffix = (value: unknown): string => {
130
138
  requireValue(typeof value === 'string' && value.length <= 253 && value.endsWith('.'));
131
139
  const labels = value.slice(0, -1).split('.');
@@ -141,12 +149,8 @@ export const snapshotPrivateNetworkPolicy = (value: unknown): IPrivateNetworkPol
141
149
  requireValue(policy.connectivity === 'member-full-mesh');
142
150
  if (policy.externalDns === null) return { connectivity: 'member-full-mesh', externalDns: null };
143
151
  const external = object(policy.externalDns, ['upstreams', 'allowAnyPublicName', 'permittedSuffixes']);
144
- const upstreams = array(external.upstreams, privateNetworkContract.maximumUpstreams, entry => {
145
- const upstream = object(entry, ['address', 'port']);
146
- const port = counter(upstream.port);
147
- requireValue(port <= 65535);
148
- return { address: ipv4(upstream.address), port };
149
- });
152
+ const upstreams = array(external.upstreams, privateNetworkContract.maximumUpstreams,
153
+ entry => endpoint(object(entry, ['address', 'port'])));
150
154
  requireValue(upstreams.length > 0 && new Set(upstreams.map(upstream => `${upstream.address}:${upstream.port}`)).size === upstreams.length);
151
155
  requireValue(typeof external.allowAnyPublicName === 'boolean');
152
156
  const permittedSuffixes = sorted(array(external.permittedSuffixes, privateNetworkContract.maximumPermittedSuffixes, publicSuffix));
@@ -223,6 +227,11 @@ export interface IServicePrivateNetworkDnsPolicy {
223
227
  networkSuffixes: Array<{ networkId: string; networkRevision: number; dnsSuffix: string }>;
224
228
  /** Empty or exactly the selected default network; never an ordered network union. */
225
229
  searchDomains: string[];
230
+ /**
231
+ * The networks' intersection for an attached service. A service attached to no
232
+ * network forwards any public name to the authority's resolvers, or has none
233
+ * when the authority declares no resolver.
234
+ */
226
235
  externalDns: {
227
236
  upstreams: Array<{ address: string; port: number }>;
228
237
  allowAnyPublicName: boolean;
@@ -243,12 +252,23 @@ const minimalSuffixes = (values: string[]): string[] => {
243
252
  * membership authentication, endpoint readiness, a DNS lease or a packet grant.
244
253
  * The caller must fence these exact revisions before issuing runtime authority.
245
254
  * An inactive/missing/extra network is rejected, never silently omitted.
255
+ *
256
+ * `resolvers` is the complete resolver list of the protected authority the
257
+ * membership is projected under. It decides external resolution only for a
258
+ * service attached to no private network: such a service forwards any public
259
+ * name to the first `maximumUpstreams` of those resolvers in address and port
260
+ * order, and an authority declaring none leaves it without external resolution.
261
+ * An attached service is decided by its networks alone, so a network denying
262
+ * external DNS keeps denying it whatever the authority declares.
246
263
  */
247
264
  export const composeServicePrivateNetworkDnsPolicy = (
248
- membershipValue: unknown, networksValue: unknown,
265
+ membershipValue: unknown, networksValue: unknown, resolversValue: unknown,
249
266
  ): IServicePrivateNetworkDnsPolicy => safe(() => {
250
267
  const member = snapshotServicePrivateNetworks(membershipValue);
251
268
  const networks = array(networksValue, privateNetworkContract.maximumAttachments, snapshotPrivateNetwork);
269
+ const resolvers = array(resolversValue, runtimeNetworkAuthorityContract.maximumResolvers,
270
+ entry => endpoint(object(entry, ['id', 'address', 'port']))).sort(byEndpoint);
271
+ requireValue(new Set(resolvers.map(resolver => `${resolver.address}:${resolver.port}`)).size === resolvers.length);
252
272
  requireValue(networks.length === member.attachments.length
253
273
  && new Set(networks.map(network => network.id)).size === networks.length
254
274
  && new Set(networks.map(network => network.networkDnsKey)).size === networks.length
@@ -261,11 +281,15 @@ export const composeServicePrivateNetworkDnsPolicy = (
261
281
  const searchDomains = member.defaultNetworkId === null ? []
262
282
  : [networkSuffixes.find(network => network.networkId === member.defaultNetworkId)!.dnsSuffix];
263
283
  let externalDns: IServicePrivateNetworkDnsPolicy['externalDns'] = null;
264
- if (ordered.length > 0 && ordered.every(network => network.policy.externalDns !== null)) {
265
- const policies = ordered.map(network => network.policy.externalDns!);
284
+ const policies: Array<NonNullable<IPrivateNetworkPolicy['externalDns']>> = [];
285
+ for (const network of ordered) if (network.policy.externalDns !== null) policies.push(network.policy.externalDns);
286
+ if (ordered.length === 0 && resolvers.length > 0) {
287
+ externalDns = { upstreams: resolvers.slice(0, privateNetworkContract.maximumUpstreams),
288
+ allowAnyPublicName: true, permittedSuffixes: [] };
289
+ } else if (ordered.length > 0 && policies.length === ordered.length) {
266
290
  const upstreams = policies[0].upstreams.filter(upstream => policies.every(policy => policy.upstreams.some(
267
291
  candidate => candidate.address === upstream.address && candidate.port === upstream.port,
268
- ))).sort((left, right) => left.address < right.address ? -1 : left.address > right.address ? 1 : left.port - right.port);
292
+ ))).sort(byEndpoint);
269
293
  // null is the unrestricted public-name set during intersection, not denial.
270
294
  let permittedSuffixes: string[] | null = null;
271
295
  for (const policy of policies) {
@@ -1,11 +1,13 @@
1
1
  import type { IRuntimeAssignment } from './runtimeassignment.js';
2
- import { snapshotRuntimeAssignment, validateRuntimeAssignment } from './runtimeassignment.js';
2
+ import { isRuntimeReplicaId, snapshotRuntimeAssignment, validateRuntimeAssignment } from './runtimeassignment.js';
3
3
  import type { IRuntimeConfigLogPolicy } from './runtimeconfig.js';
4
4
  import type { IRuntimeSessionBinding } from './runtimesession.js';
5
5
  import { snapshotRuntimeSessionBinding } from './runtimesession.js';
6
6
  import {
7
- captureNetwork, networkCounter, networkEqual, networkId, networkList, networkRecord, requireNetwork,
7
+ captureNetwork, networkCounter, networkEqual, networkId, networkList, networkNodeId, networkRecord, networkSorted,
8
+ requireNetwork,
8
9
  } from '../private/networkcontract.js';
10
+ import { isOneOf, variantOf } from '../private/producercontract.js';
9
11
  import { isRuntimeLogPolicy, runtimePolicyLimits } from '../private/runtimepolicy.js';
10
12
 
11
13
  /** The container output a log line was written to. */
@@ -333,3 +335,306 @@ export const bindReportRuntimeAssignmentLogsResponse = (
333
335
  && response.sequence === batch.sequence);
334
336
  return response;
335
337
  });
338
+
339
+ // Reading kept logs
340
+
341
+ /**
342
+ * Why a controller no longer holds batches it accepted: `batch-cap`, its bound on how many batches
343
+ * it keeps of one attempt; `byte-cap`, its bound on the text it keeps of one attempt; both drop
344
+ * the oldest batches first; `retention`, the config's `logs.retentionMs` since it accepted them.
345
+ * A node delivers about once a second, so a workload that writes now and then sends many small
346
+ * batches, which the batch bound holds where the text bound would not.
347
+ */
348
+ export type TRuntimeAssignmentLogTruncationReason = 'batch-cap' | 'byte-cap' | 'retention';
349
+
350
+ /** One batch the controller accepted and still holds, as the node sent it. */
351
+ export interface IRuntimeAssignmentLogBatchRecord {
352
+ kind: 'batch';
353
+ captureId: string;
354
+ sequence: number;
355
+ /** When the controller accepted the batch, in milliseconds since the Unix epoch. */
356
+ acceptedAt: number;
357
+ final: boolean;
358
+ entries: TRuntimeAssignmentLogEntry[];
359
+ }
360
+
361
+ /**
362
+ * Sequence numbers of one capture whose batches the controller did not receive or no longer holds
363
+ * (its own loss): numbers that never arrived and that the node did not account for, and batches it
364
+ * accepted whose payload it has lost. A gap the node left by discarding is not one: the batch
365
+ * after it opens with the `not-kept` loss that counts it.
366
+ */
367
+ export interface IRuntimeAssignmentLogGapRecord {
368
+ kind: 'gap';
369
+ captureId: string;
370
+ fromSequence: number;
371
+ toSequence: number;
372
+ }
373
+
374
+ /**
375
+ * Batches of one capture the controller accepted and has since dropped: every batch it held of the
376
+ * capture up to and including `throughSequence`, oldest first, so a truncation is always the start
377
+ * of what remains of a capture. Gaps inside that range are no longer stated. The counts are exact.
378
+ */
379
+ export interface IRuntimeAssignmentLogTruncationRecord {
380
+ kind: 'truncated';
381
+ captureId: string;
382
+ throughSequence: number;
383
+ /** In the canonical order `batch-cap`, `byte-cap`, `retention`; duplicate-free, at least one. */
384
+ reasons: TRuntimeAssignmentLogTruncationReason[];
385
+ /** The accepted batches dropped, at least one and at most `throughSequence`. */
386
+ batches: number;
387
+ /** The UTF-8 bytes of `text` the dropped batches held. */
388
+ bytes: number;
389
+ /** The entries, lines and losses alike, the dropped batches held. */
390
+ entries: number;
391
+ }
392
+
393
+ export type TRuntimeAssignmentLogRecord =
394
+ | IRuntimeAssignmentLogBatchRecord | IRuntimeAssignmentLogGapRecord | IRuntimeAssignmentLogTruncationRecord;
395
+
396
+ /**
397
+ * One attempt whose logs the controller keeps, as an operator reads it. The controller keeps an
398
+ * attempt's logs from the moment it issues the attempt, so an attempt that failed before writing a
399
+ * line is listed with nothing held, and an attempt its slot released stays readable until
400
+ * `expiresAt`.
401
+ */
402
+ export interface IRuntimeAssignmentLogAttemptView {
403
+ serviceId: string;
404
+ replicaId: string;
405
+ nodeId: string;
406
+ assignmentId: string;
407
+ attemptId: string;
408
+ /** When the controller issued the attempt, in milliseconds since the Unix epoch. */
409
+ issuedAt: number;
410
+ /** When it last accepted a batch of the attempt; null until it accepted one. */
411
+ lastAcceptedAt: number | null;
412
+ /**
413
+ * When the controller drops the attempt's logs whole: the latest of `issuedAt`, `lastAcceptedAt`
414
+ * and the time the attempt's slot released it, plus the attempt's `logs.retentionMs`. Null while
415
+ * the slot still holds the attempt: no expiry is scheduled, however quiet the attempt is.
416
+ */
417
+ expiresAt: number | null;
418
+ /** The captures of the attempt the controller accepted a batch of. */
419
+ captures: number;
420
+ /** Those of them whose final batch it accepted. */
421
+ capturesEnded: number;
422
+ /** The UTF-8 bytes of `text` the controller holds of the attempt. */
423
+ keptBytes: number;
424
+ /** The accepted batches of the attempt it has since dropped, stated in truncation records. */
425
+ truncatedBatches: number;
426
+ }
427
+
428
+ /** One page of a service's attempts, newest issued first, ties in `assignmentId` order. */
429
+ export interface IRuntimeAssignmentLogAttemptPage {
430
+ attempts: IRuntimeAssignmentLogAttemptView[];
431
+ /** Where the next page starts; null when this page is the last. Opaque to the reader. */
432
+ nextCursor: string | null;
433
+ }
434
+
435
+ /**
436
+ * One page of one attempt's kept logs, in delivery order: captures in the order the controller
437
+ * first accepted a batch of each, and within a capture by sequence. A capture's truncation record
438
+ * comes first, a gap record directly before the batch that follows it.
439
+ */
440
+ export interface IRuntimeAssignmentLogPage {
441
+ attempt: IRuntimeAssignmentLogAttemptView;
442
+ records: TRuntimeAssignmentLogRecord[];
443
+ /** Where the next page starts; null when this page is the last. Opaque to the reader. */
444
+ nextCursor: string | null;
445
+ }
446
+
447
+ /**
448
+ * Why a controller refuses a log read: `attempt-not-found` (it keeps no logs of that attempt for
449
+ * that service, never did or no longer does), `cursor-invalid` (a cursor it did not hand out for
450
+ * this read) and `limit-invalid` (a `limit` outside its bound).
451
+ */
452
+ export type TRuntimeAssignmentLogReadRefusal = 'attempt-not-found' | 'cursor-invalid' | 'limit-invalid';
453
+
454
+ /** The named refusal of a log read, sent as `TypedResponseError.errorData`. */
455
+ export interface IRuntimeAssignmentLogReadErrorData {
456
+ code: TRuntimeAssignmentLogReadRefusal;
457
+ retryable: false;
458
+ }
459
+
460
+ export const runtimeAssignmentLogReadContract = Object.freeze({
461
+ error: 'Runtime assignment log read contract rejected.',
462
+ /** The largest `limit` `getServiceRuntimeLogAttempts` accepts; a controller refuses a larger one. */
463
+ maximumAttemptPageSize: 256,
464
+ /** The largest `limit` `getRuntimeAssignmentLogs` accepts, counted in records. */
465
+ maximumPageRecords: 256,
466
+ /**
467
+ * The most entries the records of one log page hold together: four full batches. It keeps a page
468
+ * within the node count every captured value is held to; one batch always fits.
469
+ */
470
+ maximumPageEntries: 4 * runtimeAssignmentLogContract.maximumEntriesPerBatch,
471
+ /**
472
+ * The canonical JSON of a whole log page is at most two mebibytes, and the controller closes a
473
+ * page before the next record would cross it. One batch always fits: its record is smaller than
474
+ * the report that carried it, which `maximumReportCanonicalBytes` bounded.
475
+ */
476
+ maximumPageCanonicalBytes: 2 * runtimeAssignmentLogContract.maximumReportCanonicalBytes,
477
+ } as const);
478
+
479
+ const readFail = (): never => { throw new Error(runtimeAssignmentLogReadContract.error); };
480
+ const readSafe = <T>(operationArg: () => T): T => { try { return operationArg(); } catch { return readFail(); } };
481
+ /** The canonical order of `reasons`. */
482
+ const truncationReasons = ['batch-cap', 'byte-cap', 'retention'] as const;
483
+ const cursorOf = (valueArg: unknown): string | null => valueArg === null ? null : networkId(valueArg);
484
+
485
+ const batchRecordOf = (valueArg: unknown): IRuntimeAssignmentLogBatchRecord => {
486
+ const value = networkRecord(valueArg, ['kind', 'captureId', 'sequence', 'acceptedAt', 'final', 'entries']);
487
+ const entries = networkList(value.entries, runtimeAssignmentLogContract.maximumEntriesPerBatch, entryOf);
488
+ requireNetwork(value.kind === 'batch' && typeof value.final === 'boolean' && (entries.length > 0 || value.final));
489
+ return {
490
+ kind: 'batch', captureId: networkId(value.captureId), sequence: networkCounter(value.sequence),
491
+ acceptedAt: networkCounter(value.acceptedAt), final: value.final, entries,
492
+ };
493
+ };
494
+
495
+ const gapRecordOf = (valueArg: unknown): IRuntimeAssignmentLogGapRecord => {
496
+ const value = networkRecord(valueArg, ['kind', 'captureId', 'fromSequence', 'toSequence']);
497
+ const fromSequence = networkCounter(value.fromSequence), toSequence = networkCounter(value.toSequence);
498
+ requireNetwork(value.kind === 'gap' && fromSequence <= toSequence);
499
+ return { kind: 'gap', captureId: networkId(value.captureId), fromSequence, toSequence };
500
+ };
501
+
502
+ const truncationRecordOf = (valueArg: unknown): IRuntimeAssignmentLogTruncationRecord => {
503
+ const value = networkRecord(
504
+ valueArg, ['kind', 'captureId', 'throughSequence', 'reasons', 'batches', 'bytes', 'entries'],
505
+ );
506
+ requireNetwork(value.kind === 'truncated');
507
+ const throughSequence = networkCounter(value.throughSequence);
508
+ const reasons = networkSorted(networkList(value.reasons, truncationReasons.length, (reasonArg) => {
509
+ requireNetwork(isOneOf(truncationReasons, reasonArg));
510
+ return reasonArg;
511
+ }), (reasonArg) => String(truncationReasons.indexOf(reasonArg)));
512
+ requireNetwork(reasons.length > 0);
513
+ return {
514
+ kind: 'truncated', captureId: networkId(value.captureId), throughSequence, reasons,
515
+ batches: networkCounter(value.batches, throughSequence), bytes: countOf(value.bytes), entries: countOf(value.entries),
516
+ };
517
+ };
518
+
519
+ const recordOf = (valueArg: unknown): TRuntimeAssignmentLogRecord => {
520
+ const kind = variantOf(valueArg, 'kind');
521
+ if (kind === 'batch') return batchRecordOf(valueArg);
522
+ if (kind === 'gap') return gapRecordOf(valueArg);
523
+ return truncationRecordOf(valueArg);
524
+ };
525
+
526
+ const opensWithNotKept = (recordArg: IRuntimeAssignmentLogBatchRecord): boolean => {
527
+ const first = recordArg.entries[0];
528
+ return first !== undefined && first.kind === 'lost' && first.reason === 'not-kept';
529
+ };
530
+
531
+ /**
532
+ * Delivery order within a page. Each capture's records are contiguous and never return once
533
+ * another capture's began. A truncation only opens its capture. Every other record continues where
534
+ * the one before it ended: a gap at the next number, never right after another gap, and a batch at
535
+ * the next number or, past numbers the node skipped, opening with the `not-kept` loss that counts
536
+ * them. Nothing follows a final batch.
537
+ */
538
+ const requireDeliveryOrder = (recordsArg: TRuntimeAssignmentLogRecord[]): void => {
539
+ const begun = new Set<string>();
540
+ let previous: TRuntimeAssignmentLogRecord | null = null;
541
+ for (const record of recordsArg) {
542
+ const continues: boolean = previous !== null && previous.captureId === record.captureId;
543
+ if (!continues) {
544
+ requireNetwork(!begun.has(record.captureId));
545
+ begun.add(record.captureId);
546
+ }
547
+ if (previous === null || !continues) {
548
+ previous = record;
549
+ continue;
550
+ }
551
+ requireNetwork(record.kind !== 'truncated' && !(previous.kind === 'batch' && previous.final));
552
+ const next = (previous.kind === 'batch' ? previous.sequence
553
+ : previous.kind === 'gap' ? previous.toSequence : previous.throughSequence) + 1;
554
+ if (record.kind === 'gap') {
555
+ requireNetwork(previous.kind !== 'gap' && record.fromSequence === next);
556
+ } else {
557
+ requireNetwork(record.sequence === next || (record.sequence > next && opensWithNotKept(record)));
558
+ }
559
+ previous = record;
560
+ }
561
+ };
562
+
563
+ const attemptOf = (valueArg: unknown): IRuntimeAssignmentLogAttemptView => {
564
+ const value = networkRecord(valueArg, [
565
+ 'serviceId', 'replicaId', 'nodeId', 'assignmentId', 'attemptId', 'issuedAt', 'lastAcceptedAt', 'expiresAt',
566
+ 'captures', 'capturesEnded', 'keptBytes', 'truncatedBatches',
567
+ ]);
568
+ requireNetwork(isRuntimeReplicaId(value.replicaId));
569
+ const issuedAt = networkCounter(value.issuedAt);
570
+ const lastAcceptedAt = value.lastAcceptedAt === null ? null : networkCounter(value.lastAcceptedAt);
571
+ const expiresAt = value.expiresAt === null ? null : networkCounter(value.expiresAt);
572
+ const captures = countOf(value.captures), capturesEnded = countOf(value.capturesEnded);
573
+ const keptBytes = countOf(value.keptBytes), truncatedBatches = countOf(value.truncatedBatches);
574
+ requireNetwork((expiresAt === null || expiresAt > Math.max(issuedAt, lastAcceptedAt ?? issuedAt))
575
+ && capturesEnded <= captures);
576
+ // Nothing accepted means nothing captured, held or dropped.
577
+ requireNetwork(lastAcceptedAt !== null || (captures === 0 && keptBytes === 0 && truncatedBatches === 0));
578
+ return {
579
+ serviceId: networkId(value.serviceId), replicaId: value.replicaId, nodeId: networkNodeId(value.nodeId),
580
+ assignmentId: networkId(value.assignmentId), attemptId: networkId(value.attemptId), issuedAt, lastAcceptedAt,
581
+ expiresAt, captures, capturesEnded, keptBytes, truncatedBatches,
582
+ };
583
+ };
584
+
585
+ const pageLimitOf = (limitArg: unknown, maximumArg: number): number => networkCounter(limitArg, maximumArg);
586
+
587
+ /** Detached exact-schema snapshot of one attempt view. */
588
+ export const snapshotRuntimeAssignmentLogAttemptView = (valueArg: unknown): IRuntimeAssignmentLogAttemptView =>
589
+ readSafe(() => attemptOf(captureNetwork(valueArg)));
590
+
591
+ /** Detached exact-schema snapshot of one attempt page, in its one order. */
592
+ export const snapshotRuntimeAssignmentLogAttemptPage = (valueArg: unknown): IRuntimeAssignmentLogAttemptPage =>
593
+ readSafe(() => {
594
+ const value = networkRecord(captureNetwork(valueArg), ['attempts', 'nextCursor']);
595
+ const attempts = networkList(value.attempts, runtimeAssignmentLogReadContract.maximumAttemptPageSize, attemptOf);
596
+ attempts.forEach((attemptArg, indexArg) => {
597
+ const before = attempts[indexArg - 1];
598
+ requireNetwork(before === undefined || before.issuedAt > attemptArg.issuedAt
599
+ || (before.issuedAt === attemptArg.issuedAt && before.assignmentId < attemptArg.assignmentId));
600
+ });
601
+ const nextCursor = cursorOf(value.nextCursor);
602
+ requireNetwork(nextCursor === null || attempts.length > 0);
603
+ return { attempts, nextCursor };
604
+ });
605
+
606
+ /** Detached exact-schema snapshot of one log page, bounded and in delivery order. */
607
+ export const snapshotRuntimeAssignmentLogPage = (valueArg: unknown): IRuntimeAssignmentLogPage => readSafe(() => {
608
+ const value = networkRecord(
609
+ captureNetwork(valueArg, runtimeAssignmentLogReadContract.maximumPageCanonicalBytes),
610
+ ['attempt', 'records', 'nextCursor'],
611
+ );
612
+ const records = networkList(value.records, runtimeAssignmentLogReadContract.maximumPageRecords, recordOf);
613
+ requireNetwork(records.reduce((totalArg, recordArg) => totalArg + (recordArg.kind === 'batch' ? recordArg.entries.length : 0), 0)
614
+ <= runtimeAssignmentLogReadContract.maximumPageEntries);
615
+ requireDeliveryOrder(records);
616
+ const nextCursor = cursorOf(value.nextCursor);
617
+ requireNetwork(nextCursor === null || records.length > 0);
618
+ return { attempt: attemptOf(value.attempt), records, nextCursor };
619
+ });
620
+
621
+ /** Matches an attempt page to the read that asked for it: that service's attempts, within its limit. */
622
+ export const bindRuntimeAssignmentLogAttemptPage = (
623
+ valueArg: unknown, serviceIdArg: string, limitArg: number,
624
+ ): IRuntimeAssignmentLogAttemptPage => readSafe(() => {
625
+ const page = snapshotRuntimeAssignmentLogAttemptPage(valueArg);
626
+ const limit = pageLimitOf(limitArg, runtimeAssignmentLogReadContract.maximumAttemptPageSize);
627
+ requireNetwork(page.attempts.length <= limit && page.attempts.every((attemptArg) => attemptArg.serviceId === serviceIdArg));
628
+ return page;
629
+ });
630
+
631
+ /** Matches a log page to the read that asked for it: that attempt of that service, within its limit. */
632
+ export const bindRuntimeAssignmentLogPage = (
633
+ valueArg: unknown, serviceIdArg: string, assignmentIdArg: string, limitArg: number,
634
+ ): IRuntimeAssignmentLogPage => readSafe(() => {
635
+ const page = snapshotRuntimeAssignmentLogPage(valueArg);
636
+ const limit = pageLimitOf(limitArg, runtimeAssignmentLogReadContract.maximumPageRecords);
637
+ requireNetwork(page.records.length <= limit && page.attempt.serviceId === serviceIdArg
638
+ && page.attempt.assignmentId === assignmentIdArg);
639
+ return page;
640
+ });
@@ -46,14 +46,23 @@ export const networkSafe = <T>(operation: () => T): T => {
46
46
  }
47
47
  };
48
48
 
49
- /** Bound decoded transport input before canonicalization; never execute getters/toJSON. */
50
- export const captureNetwork = (value: unknown): unknown => networkSafe(() => {
49
+ /** The canonical JSON bound every runtime network contract captures with, unless it states its own. */
50
+ export const networkMaximumCanonicalBytes = 1_048_576;
51
+
52
+ /**
53
+ * Bound decoded transport input before canonicalization; never execute getters/toJSON.
54
+ * `maximumBytes` bounds the whole value's canonical JSON; a single string stays within
55
+ * `networkMaximumCanonicalBytes` whatever the whole may hold.
56
+ */
57
+ export const captureNetwork = (
58
+ value: unknown, maximumBytes = networkMaximumCanonicalBytes,
59
+ ): unknown => networkSafe(() => {
51
60
  const encoder = new TextEncoder(), ancestors = new Set<object>();
52
61
  let nodes = 0, bytes = 0;
53
62
  const account = (text: string) => {
54
- requireNetwork(text.length <= 1_048_576);
63
+ requireNetwork(text.length <= networkMaximumCanonicalBytes);
55
64
  bytes += encoder.encode(text).length;
56
- requireNetwork(bytes <= 1_048_576);
65
+ requireNetwork(bytes <= maximumBytes);
57
66
  };
58
67
  const copy = (item: unknown, depth: number): unknown => {
59
68
  requireNetwork(++nodes <= 65_536 && depth <= 24);
@@ -90,7 +99,7 @@ export const captureNetwork = (value: unknown): unknown => networkSafe(() => {
90
99
  } finally { ancestors.delete(item); }
91
100
  };
92
101
  const result = copy(value, 0);
93
- requireNetwork(encoder.encode(networkCanonical(result)).length <= 1_048_576);
102
+ requireNetwork(encoder.encode(networkCanonical(result)).length <= maximumBytes);
94
103
  return result;
95
104
  });
96
105
 
@@ -19,6 +19,10 @@ import type {
19
19
  import type { TSha256Digest } from '../data/immutableimage.js';
20
20
  import type { ILegacyServiceRuntimeAttestation } from '../data/deploymentlegacyimport.js';
21
21
  import type { IProtocolOffer } from '../protocol.js';
22
+ import type {
23
+ IRuntimeAssignmentLogAttemptPage,
24
+ IRuntimeAssignmentLogPage,
25
+ } from '../data/runtimeassignmentlogs.js';
22
26
  import type {
23
27
  IServiceRuntimeAssignmentView,
24
28
  IServiceRuntimeSpec,
@@ -629,6 +633,52 @@ extends plugins.typedrequestInterfaces.implementsTR<
629
633
  };
630
634
  }
631
635
 
636
+ /**
637
+ * The attempts of one service whose workload logs Cloudly keeps, newest issued first, a page at a
638
+ * time: a slot's replaced and released attempts stay readable until their logs expire, which no
639
+ * single response bounds. `limit` runs from 1 to
640
+ * `runtimeAssignmentLogReadContract.maximumAttemptPageSize`; Cloudly refuses any other with
641
+ * `IRuntimeAssignmentLogReadErrorData`. `cursor` is null for the first page, then the page's own
642
+ * `nextCursor`. `bindRuntimeAssignmentLogAttemptPage` checks an answer against its read.
643
+ */
644
+ export interface IRequest_Any_Cloudly_GetServiceRuntimeLogAttempts
645
+ extends plugins.typedrequestInterfaces.implementsTR<
646
+ plugins.typedrequestInterfaces.ITypedRequest,
647
+ IRequest_Any_Cloudly_GetServiceRuntimeLogAttempts
648
+ > {
649
+ method: 'getServiceRuntimeLogAttempts';
650
+ request: {
651
+ identity: IIdentity;
652
+ serviceId: string;
653
+ cursor: string | null;
654
+ limit: number;
655
+ };
656
+ response: IRuntimeAssignmentLogAttemptPage;
657
+ }
658
+
659
+ /**
660
+ * One attempt's kept workload logs, a page at a time in delivery order, with the gaps Cloudly found
661
+ * and the truncations it made stated in place. `limit` counts records and runs from 1 to
662
+ * `runtimeAssignmentLogReadContract.maximumPageRecords`; a page also ends before it would cross
663
+ * `maximumPageEntries` or `maximumPageCanonicalBytes`, so it may hold fewer records than `limit`
664
+ * while `nextCursor` still names more. `bindRuntimeAssignmentLogPage` checks an answer against its read.
665
+ */
666
+ export interface IRequest_Any_Cloudly_GetRuntimeAssignmentLogs
667
+ extends plugins.typedrequestInterfaces.implementsTR<
668
+ plugins.typedrequestInterfaces.ITypedRequest,
669
+ IRequest_Any_Cloudly_GetRuntimeAssignmentLogs
670
+ > {
671
+ method: 'getRuntimeAssignmentLogs';
672
+ request: {
673
+ identity: IIdentity;
674
+ serviceId: string;
675
+ assignmentId: string;
676
+ cursor: string | null;
677
+ limit: number;
678
+ };
679
+ response: IRuntimeAssignmentLogPage;
680
+ }
681
+
632
682
  /**
633
683
  * Cloudly -> admin UIs: a service's runtime spec, status or assignments changed. It carries no
634
684
  * state, so a late or repeated push can never overwrite a newer read; the UI reads the views again.