@substrat-run/kernel 0.120.0 → 0.121.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/dist/index.d.ts +9 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/list-index.d.ts.map +1 -1
- package/dist/list-index.js +5 -2
- package/dist/list-index.js.map +1 -1
- package/dist/module-migrations.d.ts +26 -0
- package/dist/module-migrations.d.ts.map +1 -0
- package/dist/module-migrations.js +21 -0
- package/dist/module-migrations.js.map +1 -0
- package/dist/platform-call.d.ts +20 -0
- package/dist/platform-call.d.ts.map +1 -1
- package/dist/platform-call.js +24 -0
- package/dist/platform-call.js.map +1 -1
- package/dist/platform-sweep.d.ts +157 -4
- package/dist/platform-sweep.d.ts.map +1 -1
- package/dist/platform-sweep.js +574 -45
- package/dist/platform-sweep.js.map +1 -1
- package/dist/scope-host.d.ts +105 -2
- package/dist/scope-host.d.ts.map +1 -1
- package/dist/scope-host.js.map +1 -1
- package/dist/sql-limits.d.ts +70 -0
- package/dist/sql-limits.d.ts.map +1 -0
- package/dist/sql-limits.js +197 -0
- package/dist/sql-limits.js.map +1 -0
- package/dist/system-switch-record.d.ts +158 -0
- package/dist/system-switch-record.d.ts.map +1 -0
- package/dist/system-switch-record.js +227 -0
- package/dist/system-switch-record.js.map +1 -0
- package/dist/ulid.d.ts +6 -0
- package/dist/ulid.d.ts.map +1 -1
- package/dist/ulid.js +21 -7
- package/dist/ulid.js.map +1 -1
- package/dist/vertical-events.d.ts +96 -2
- package/dist/vertical-events.d.ts.map +1 -1
- package/dist/vertical-events.js +230 -1
- package/dist/vertical-events.js.map +1 -1
- package/package.json +2 -2
package/dist/platform-sweep.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { drainedEvent, instant } from '@substrat-run/contracts';
|
|
1
|
+
import { drainedEvent, errorCodeOf, instant, substratError } from '@substrat-run/contracts';
|
|
2
2
|
import { backoffAt } from './scope-host.js';
|
|
3
3
|
import { MIGRATION_FLAG_THRESHOLD, migrationFleet, migrationProgress, scopeMigrationState } from './migration-progress.js';
|
|
4
4
|
import { UNDRAINED_SKIPPED_IDS } from './outbox-event.js';
|
|
@@ -134,6 +134,21 @@ export function runningVersionOf(scope, serving) {
|
|
|
134
134
|
return serving.versionId;
|
|
135
135
|
return scope.verticalVersionId;
|
|
136
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Every vertical's serving pointer, for `runningVersionOf` — one `listVerticals` read, and only
|
|
139
|
+
* when some scope is actually on a serving script, since nothing else can differ from its
|
|
140
|
+
* binding. Shared by the provision reconcile (#1653) and the cross-vertical narrowing (#1705).
|
|
141
|
+
*/
|
|
142
|
+
async function servingPointersFor(admin, actor, scopes) {
|
|
143
|
+
const serving = new Map();
|
|
144
|
+
if (!scopes.some((s) => s.servingRef))
|
|
145
|
+
return serving;
|
|
146
|
+
for (const v of await admin.listVerticals(actor)) {
|
|
147
|
+
if (v.servingRef && v.servingVersionId)
|
|
148
|
+
serving.set(v.slug, { ref: v.servingRef, versionId: v.servingVersionId });
|
|
149
|
+
}
|
|
150
|
+
return serving;
|
|
151
|
+
}
|
|
137
152
|
/**
|
|
138
153
|
* This pass's share of the behind scopes: at most `batch`, as a contiguous window over
|
|
139
154
|
* the id order, starting at `rng()` of the way round and wrapping. Deterministic for a
|
|
@@ -374,16 +389,7 @@ export async function runPlatformSweep(host, options) {
|
|
|
374
389
|
deferred: 0,
|
|
375
390
|
};
|
|
376
391
|
const scopes = (await host.admin.listScopes(options.actor, { status: 'active' })).filter((s) => isPrimaryScope(s) && !failedThisPass.has(s.id));
|
|
377
|
-
|
|
378
|
-
// is actually on a serving script, since nothing else can differ from its binding.
|
|
379
|
-
const serving = new Map();
|
|
380
|
-
if (scopes.some((s) => s.servingRef)) {
|
|
381
|
-
for (const v of await host.admin.listVerticals(options.actor)) {
|
|
382
|
-
if (v.servingRef && v.servingVersionId) {
|
|
383
|
-
serving.set(v.slug, { ref: v.servingRef, versionId: v.servingVersionId });
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
392
|
+
const serving = await servingPointersFor(host.admin, options.actor, scopes);
|
|
387
393
|
const behind = [];
|
|
388
394
|
for (const s of scopes) {
|
|
389
395
|
const running = runningVersionOf(s, s.vertical ? serving.get(s.vertical) : null);
|
|
@@ -736,7 +742,9 @@ export async function runPlatformSweep(host, options) {
|
|
|
736
742
|
* One pass over every cross-vertical edge (#1705). Bounded like the event drain: one batch per
|
|
737
743
|
* edge per pass, reported rather than looped, so one busy producer cannot starve the rest.
|
|
738
744
|
*/
|
|
739
|
-
async function sweepCrossVertical(host, options, cv, failedThisPass, report
|
|
745
|
+
async function sweepCrossVertical(host, options, cv, failedThisPass, report,
|
|
746
|
+
/** Run only this producer's outgoing edges (the router kick). Absent: every edge. */
|
|
747
|
+
only) {
|
|
740
748
|
const out = {
|
|
741
749
|
edges: [],
|
|
742
750
|
delivered: 0,
|
|
@@ -763,7 +771,21 @@ async function sweepCrossVertical(host, options, cv, failedThisPass, report) {
|
|
|
763
771
|
// fork is a copy of somebody's data. Delivering into it would feed a copy as though it were
|
|
764
772
|
// the install, and reading from it would publish a copy's history to another vertical. A
|
|
765
773
|
// preview is not the install either.
|
|
766
|
-
|
|
774
|
+
//
|
|
775
|
+
// A producer-scoped run reads only that tenant's scopes: both ends of an edge are in one
|
|
776
|
+
// tenant, so nothing outside it can be a consumer of this producer or change how it resolves.
|
|
777
|
+
const scopes = (await host.admin.listScopes(options.actor, { status: 'active', ...(only ? { tenantId: only.tenantId } : {}) })).filter((s) => isPrimaryScope(s) && s.vertical !== null);
|
|
778
|
+
// The kick names a scope, and the edges run are the ones whose producer RESOLVES to it. A
|
|
779
|
+
// fork, a preview, a second install or a scope of another tenant is not a producer, so a
|
|
780
|
+
// kick naming one runs nothing (the sweep would resolve the same way).
|
|
781
|
+
let from = null;
|
|
782
|
+
if (only) {
|
|
783
|
+
const named = scopes.find((s) => s.id === only.scopeId && s.tenantId === only.tenantId);
|
|
784
|
+
const resolved = named ? resolveVerticalInstanceFrom(scopes, named.tenantId, named.vertical) : null;
|
|
785
|
+
if (!named || resolved?.outcome !== 'resolved' || resolved.instance.scopeId !== named.id)
|
|
786
|
+
return out;
|
|
787
|
+
from = named.vertical;
|
|
788
|
+
}
|
|
767
789
|
const record = (edge) => {
|
|
768
790
|
out.edges.push(edge);
|
|
769
791
|
out.delivered += edge.delivered;
|
|
@@ -782,6 +804,12 @@ async function sweepCrossVertical(host, options, cv, failedThisPass, report) {
|
|
|
782
804
|
// Idle writes no row: one green row per edge per tick would bury the ones that matter.
|
|
783
805
|
if (edge.state === 'idle')
|
|
784
806
|
return;
|
|
807
|
+
// A kick pass (`only`) can run every few seconds for a busy producer. It writes a row only
|
|
808
|
+
// for an edge that moved or paused. A standing failure (an old consumer, an unresolved
|
|
809
|
+
// producer) is the scheduled sweep's to record, once per tick, rather than once per kick:
|
|
810
|
+
// otherwise one broken consumer beside a busy producer files thousands of identical rows.
|
|
811
|
+
if (only && edge.state !== 'delivered' && edge.state !== 'paused')
|
|
812
|
+
return;
|
|
785
813
|
options.recordSweepRun?.({
|
|
786
814
|
kind: 'vertical-events',
|
|
787
815
|
unit: `${edge.consumer.scopeId}:${edge.producer.vertical}`,
|
|
@@ -799,7 +827,47 @@ async function sweepCrossVertical(host, options, cv, failedThisPass, report) {
|
|
|
799
827
|
};
|
|
800
828
|
// Narrowed BEFORE any scope is called, then capped. The resolution below still needs every
|
|
801
829
|
// primary scope (a producer is any of them), and that is the one directory read above.
|
|
802
|
-
|
|
830
|
+
// Contained: the narrowing may read the version registry (the control plane's reach), and a
|
|
831
|
+
// failed read must cost this phase one pass, not sink the phases after it. No scope is called,
|
|
832
|
+
// every watermark holds, and the next pass asks again.
|
|
833
|
+
// Scopes kept only because the narrowing could not judge them: their "imports nothing" is an answer.
|
|
834
|
+
const doubtful = new Set();
|
|
835
|
+
// Scopes the narrowing KNOWS import: their "imports nothing" contradicts the registry.
|
|
836
|
+
const knownImporters = new Set();
|
|
837
|
+
let candidates;
|
|
838
|
+
try {
|
|
839
|
+
candidates = await candidatesOf(scopes, {
|
|
840
|
+
...(from !== null ? { from } : {}),
|
|
841
|
+
known: (scopeIds) => {
|
|
842
|
+
for (const id of scopeIds)
|
|
843
|
+
knownImporters.add(id);
|
|
844
|
+
},
|
|
845
|
+
doubt: (unit, reason, scopeIds) => {
|
|
846
|
+
for (const id of scopeIds)
|
|
847
|
+
doubtful.add(id);
|
|
848
|
+
// A kick pass leaves doubt to the sweep: see the filter below and `record`.
|
|
849
|
+
if (only)
|
|
850
|
+
return;
|
|
851
|
+
options.recordSweepRun?.({
|
|
852
|
+
kind: 'vertical-events',
|
|
853
|
+
unit: `version:${unit}`,
|
|
854
|
+
outcome: 'failed',
|
|
855
|
+
operation: 'sweep.vertical-events:narrowing',
|
|
856
|
+
error: `the version registry cannot say whether ${unit} imports anything (${reason}); ` +
|
|
857
|
+
`its scopes are asked directly this pass`,
|
|
858
|
+
});
|
|
859
|
+
},
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
catch (err) {
|
|
863
|
+
report.errors.push({ kind: 'vertical-events', id: 'candidates', error: message(err) });
|
|
864
|
+
return out;
|
|
865
|
+
}
|
|
866
|
+
// A kick pass calls only the consumers the registry KNOWS import from this producer. A scope
|
|
867
|
+
// kept as doubt is asked by the scheduled sweep, once per tick. Asking it on every kick would
|
|
868
|
+
// cost a call, and a row, per flagged response.
|
|
869
|
+
if (only)
|
|
870
|
+
candidates = candidates.filter((c) => !doubtful.has(c.id));
|
|
803
871
|
out.candidates = candidates.length;
|
|
804
872
|
const configuredCap = cv.maxConsumers ?? CROSS_VERTICAL_CONSUMERS_PER_PASS;
|
|
805
873
|
const cap = Number.isFinite(configuredCap) && configuredCap >= 0 ? Math.floor(configuredCap) : CROSS_VERTICAL_CONSUMERS_PER_PASS;
|
|
@@ -808,20 +876,38 @@ async function sweepCrossVertical(host, options, cv, failedThisPass, report) {
|
|
|
808
876
|
await mapBounded(visiting, options.concurrency ?? 8, async (consumer) => {
|
|
809
877
|
if (failedThisPass.has(consumer.id))
|
|
810
878
|
return;
|
|
879
|
+
// The consumer side, failing before any producer is named: an edge to `*`, so it lands in
|
|
880
|
+
// the sweep-run rows under `<scope>:*` beside the per-producer edges, not only in `errors`.
|
|
881
|
+
const consumerFailed = (reason) => record({
|
|
882
|
+
tenantId: consumer.tenantId,
|
|
883
|
+
consumer: { scopeId: consumer.id, vertical: consumer.vertical ?? '' },
|
|
884
|
+
producer: { vertical: '*', scopeId: null },
|
|
885
|
+
state: 'failed',
|
|
886
|
+
delivered: 0,
|
|
887
|
+
deadLettered: 0,
|
|
888
|
+
withheld: 0,
|
|
889
|
+
duplicates: 0,
|
|
890
|
+
reason,
|
|
891
|
+
});
|
|
811
892
|
let state;
|
|
812
893
|
try {
|
|
813
894
|
state = await reach.importState(consumer.tenantId, consumer.id);
|
|
814
895
|
}
|
|
815
896
|
catch (err) {
|
|
816
|
-
|
|
897
|
+
consumerFailed(`could not read the consumer's imports: ${message(err)}`);
|
|
817
898
|
return;
|
|
818
899
|
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
900
|
+
// A scope the narrowing KNOWS imports (`hint.known`) whose deployment answers that it imports
|
|
901
|
+
// NOTHING: the two disagree, and the scope is not running the code the registry describes (a
|
|
902
|
+
// push that did not reach it, or a reconcile still owed). Said, not skipped: skipping would
|
|
903
|
+
// make every edge into this scope disappear without a trace. Any other candidate (a superset
|
|
904
|
+
// narrowing, which the contract allows, or a doubtful scope) answering "nothing" has answered.
|
|
905
|
+
if (state.consumes.length === 0 && knownImporters.has(consumer.id)) {
|
|
906
|
+
consumerFailed("the version registry says this scope's code imports events, but its deployment answers that it " +
|
|
907
|
+
'imports nothing — it is not running the version the registry names; redeploy or reconcile it');
|
|
908
|
+
return;
|
|
824
909
|
}
|
|
910
|
+
const bySource = wantsBySource(state.consumes, (f) => from === null || f === from);
|
|
825
911
|
// Sequential per consumer: its edges share the consumer's serialization queue anyway,
|
|
826
912
|
// and one consumer with many sources must not hold more than one slot of the pass.
|
|
827
913
|
for (const [from, wants] of [...bySource.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
@@ -830,46 +916,259 @@ async function sweepCrossVertical(host, options, cv, failedThisPass, report) {
|
|
|
830
916
|
});
|
|
831
917
|
return out;
|
|
832
918
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
919
|
+
/**
|
|
920
|
+
* Run ONE producer's outgoing edges now (#1705 PR 2): the half of the router kick that makes a
|
|
921
|
+
* cross-vertical event arrive in seconds rather than at the next sweep.
|
|
922
|
+
*
|
|
923
|
+
* The same phase, under the same rules, narrowed. It reads the producer's tenant only, runs only
|
|
924
|
+
* edges whose producer RESOLVES to `producer` (so a fork, a preview or a second install runs
|
|
925
|
+
* nothing), asks `candidates` with `{ from }` so a consumer that imports nothing from this
|
|
926
|
+
* vertical is never called, and keeps the per-pass consumer cap. What it moves is exactly what
|
|
927
|
+
* the next sweep would have moved: the watermark's compare-and-set makes the two safe to overlap,
|
|
928
|
+
* and an edge a kick has taken reads as `idle` to the sweep that follows.
|
|
929
|
+
*/
|
|
930
|
+
export async function runCrossVerticalFrom(host, options, producer) {
|
|
931
|
+
const report = { errors: [] };
|
|
932
|
+
const crossVertical = await sweepCrossVertical(host, options, options.crossVertical, new Set(), report, producer);
|
|
933
|
+
return { crossVertical, errors: report.errors };
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* The control plane's `CrossVerticalReach.candidates` (#1705 PR 2): narrow the listed scopes to
|
|
937
|
+
* those whose RUNNING version may import, read from the version registry and never from a scope.
|
|
938
|
+
*
|
|
939
|
+
* Per pass: one `listVerticals` (the serving pointers, only when some scope is on a serving
|
|
940
|
+
* script), and one `readImports` per DISTINCT running version, at most `concurrency` in flight.
|
|
941
|
+
* The control plane's reader is an unaudited directory read behind a cache keyed (slug,
|
|
942
|
+
* version), since a pushed version's manifest never changes. So after the first pass it costs
|
|
943
|
+
* no read at all.
|
|
944
|
+
*
|
|
945
|
+
* Only a version that says it imports nothing is dropped (`none`: no manifest, no registry, no
|
|
946
|
+
* `imports` key). A version the registry cannot answer for (`unreadable`: a manifest that does
|
|
947
|
+
* not parse, a malformed row, a version the registry does not know, a failed read) keeps its
|
|
948
|
+
* scopes as candidates, and the scope's own `importState` decides. Excluding a consumer wrongly
|
|
949
|
+
* loses its edge with no trace. Including one wrongly costs one call. Each such version is
|
|
950
|
+
* reported once per pass through `hint.doubt`, and a failure on one version never stops the
|
|
951
|
+
* others.
|
|
952
|
+
*/
|
|
953
|
+
/**
|
|
954
|
+
* What each scope's RUNNING version imports, from the registry (#1705): one `listVerticals` when
|
|
955
|
+
* a scope is on a serving script, then one `readImports` per distinct running version, at most
|
|
956
|
+
* `concurrency` in flight. A failure on one version never stops the others: a read that throws is
|
|
957
|
+
* reported as `threw` beside the fact, since a caller may treat "the registry could not be asked"
|
|
958
|
+
* differently from "the manifest does not parse". A scope bound to no version gets no fact. The narrowing and the promote gate
|
|
959
|
+
* both ask it, so the two cannot come to disagree about which code a scope runs.
|
|
960
|
+
*/
|
|
961
|
+
async function runningImportsOf(admin, actor, scopes, readImports, concurrency) {
|
|
962
|
+
const serving = await servingPointersFor(admin, actor, scopes);
|
|
963
|
+
const versions = new Map();
|
|
964
|
+
const keyed = [];
|
|
965
|
+
for (const s of scopes) {
|
|
966
|
+
if (!s.vertical)
|
|
967
|
+
continue;
|
|
968
|
+
const versionId = runningVersionOf(s, serving.get(s.vertical));
|
|
969
|
+
const key = `${s.vertical}@${versionId ?? '(no version)'}`;
|
|
970
|
+
if (versionId && !versions.has(key))
|
|
971
|
+
versions.set(key, { slug: s.vertical, versionId });
|
|
972
|
+
keyed.push({ scope: s, key, versionId });
|
|
973
|
+
}
|
|
974
|
+
const facts = new Map();
|
|
975
|
+
const threw = new Map();
|
|
976
|
+
await mapBounded([...versions], concurrency, async ([key, v]) => {
|
|
977
|
+
try {
|
|
978
|
+
facts.set(key, await readImports(v.slug, v.versionId));
|
|
979
|
+
}
|
|
980
|
+
catch (err) {
|
|
981
|
+
facts.set(key, { kind: 'unreadable', reason: message(err) });
|
|
982
|
+
threw.set(key, message(err));
|
|
983
|
+
}
|
|
844
984
|
});
|
|
845
|
-
|
|
846
|
-
|
|
985
|
+
return keyed.map((k) => ({ ...k, fact: facts.get(k.key), ...(threw.has(k.key) ? { threw: threw.get(k.key) } : {}) }));
|
|
986
|
+
}
|
|
987
|
+
export function registryImportCandidates(input) {
|
|
988
|
+
return async (scopes, hint) => {
|
|
989
|
+
const running = await runningImportsOf(input.admin, input.actor, scopes, input.readImports, input.concurrency ?? 8);
|
|
990
|
+
const doubted = new Map();
|
|
991
|
+
const known = [];
|
|
992
|
+
const out = [];
|
|
993
|
+
for (const { scope, key, fact: read } of running) {
|
|
994
|
+
const fact = read ?? {
|
|
995
|
+
kind: 'unreadable',
|
|
996
|
+
reason: 'the scope names no version the registry could be asked about',
|
|
997
|
+
};
|
|
998
|
+
if (fact.kind === 'none')
|
|
999
|
+
continue;
|
|
1000
|
+
if (fact.kind === 'imports') {
|
|
1001
|
+
const from = hint?.from;
|
|
1002
|
+
if (from !== undefined ? fact.rows.some((r) => r.from === from) : fact.rows.length > 0) {
|
|
1003
|
+
out.push(scope);
|
|
1004
|
+
known.push(scope.id);
|
|
1005
|
+
}
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
out.push(scope);
|
|
1009
|
+
const d = doubted.get(key) ?? { reason: fact.reason, scopeIds: [] };
|
|
1010
|
+
d.scopeIds.push(scope.id);
|
|
1011
|
+
doubted.set(key, d);
|
|
1012
|
+
}
|
|
1013
|
+
for (const [key, d] of doubted)
|
|
1014
|
+
hint?.doubt?.(key, d.reason, d.scopeIds);
|
|
1015
|
+
if (known.length > 0)
|
|
1016
|
+
hint?.known?.(known);
|
|
1017
|
+
return out;
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Who a promote breaks (#1705 PR 3): the installed consumers whose RUNNING code imports an
|
|
1022
|
+
* exported (type, schemaVersion) that the outgoing version promised and the incoming one drops
|
|
1023
|
+
* or exports at another version. The promote gate refuses on a non-empty answer unless it is
|
|
1024
|
+
* acknowledged (`exportBreak`), and the refusal lists them.
|
|
1025
|
+
*
|
|
1026
|
+
* - **What changed** is outgoing versus incoming, from the two versions' registries. An outgoing
|
|
1027
|
+
* version whose registry states no exports (a first promote, or a push by a CLI older than
|
|
1028
|
+
* 0.34.0) promised nothing the registry can name, so nothing is judged broken. Nothing it
|
|
1029
|
+
* exported at run time could be listed either, which is what the permission-digest gate
|
|
1030
|
+
* beside this one is for.
|
|
1031
|
+
* - **Who is a consumer** is the edge's own rule: a primary, active scope of ANOTHER vertical, in
|
|
1032
|
+
* a tenant where the producer is installed (any primary, active install, so two installs still
|
|
1033
|
+
* count), whose running version (`runningVersionOf`) declares the import. That over-approximates
|
|
1034
|
+
* on purpose, for a listed vertical's tenants still pinned to an older producer version: a
|
|
1035
|
+
* refusal that names one tenant too many costs an acknowledgement, and one that names too few
|
|
1036
|
+
* costs a silent stall.
|
|
1037
|
+
* - **A consumer version whose manifest does not parse** is not judged. It cannot be said to
|
|
1038
|
+
* break, and refusing every promote over a manifest nobody can parse would make the gate one
|
|
1039
|
+
* nobody can pass. A read that THREW is different: the registry could not be asked, so whom the
|
|
1040
|
+
* promote breaks is unknown, and this throws. The gate then refuses rather than failing open.
|
|
1041
|
+
* A failed directory read throws the same way.
|
|
1042
|
+
*
|
|
1043
|
+
* Cost: nothing at all unless an export changed. Then one fleet `listScopes`, one
|
|
1044
|
+
* `listVerticals` when a scope is on a serving script, and one `readImports` per distinct
|
|
1045
|
+
* running consumer version, at most `concurrency` in flight.
|
|
1046
|
+
*/
|
|
1047
|
+
export async function exportBreaksOf(input) {
|
|
1048
|
+
// An outgoing manifest that does not parse promised SOMETHING nobody can read. Judging it as
|
|
1049
|
+
// "promised nothing" would pass every break unacknowledged, so it refuses, as a thrown read does.
|
|
1050
|
+
if (input.outgoing.kind === 'unreadable') {
|
|
1051
|
+
throw substratError('unavailable', `cannot say whom this promotion breaks: the outgoing version's exports could not be read (${input.outgoing.reason})`);
|
|
1052
|
+
}
|
|
1053
|
+
if (input.outgoing.kind !== 'exports')
|
|
1054
|
+
return [];
|
|
1055
|
+
const incoming = new Map(input.incoming.kind === 'exports' ? input.incoming.rows.map((r) => [r.type, r.schemaVersion]) : []);
|
|
1056
|
+
const changed = new Map();
|
|
1057
|
+
for (const r of input.outgoing.rows) {
|
|
1058
|
+
const now = incoming.get(r.type) ?? null;
|
|
1059
|
+
if (now !== r.schemaVersion)
|
|
1060
|
+
changed.set(r.type, { schemaVersion: r.schemaVersion, incoming: now });
|
|
1061
|
+
}
|
|
1062
|
+
if (changed.size === 0)
|
|
1063
|
+
return [];
|
|
1064
|
+
const scopes = (await input.admin.listScopes(input.actor, { status: 'active' })).filter((s) => isPrimaryScope(s) && s.vertical !== null);
|
|
1065
|
+
const installedIn = new Set(scopes.filter((s) => s.vertical === input.producer).map((s) => s.tenantId));
|
|
1066
|
+
const consumers = scopes.filter((s) => s.vertical !== input.producer && installedIn.has(s.tenantId));
|
|
1067
|
+
if (consumers.length === 0)
|
|
1068
|
+
return [];
|
|
1069
|
+
const out = [];
|
|
1070
|
+
for (const { scope, key, versionId, fact, threw } of await runningImportsOf(input.admin, input.actor, consumers, input.readImports, input.concurrency ?? 8)) {
|
|
1071
|
+
if (threw !== undefined) {
|
|
1072
|
+
throw substratError('unavailable', `cannot say whom this promotion breaks: the registry could not be asked what ${key} imports (${threw}) — retry`);
|
|
1073
|
+
}
|
|
1074
|
+
if (fact?.kind !== 'imports')
|
|
1075
|
+
continue;
|
|
1076
|
+
for (const row of fact.rows) {
|
|
1077
|
+
const hit = row.from === input.producer ? changed.get(row.type) : undefined;
|
|
1078
|
+
if (!hit || hit.schemaVersion !== row.schemaVersion)
|
|
1079
|
+
continue;
|
|
1080
|
+
out.push({
|
|
1081
|
+
tenantId: scope.tenantId,
|
|
1082
|
+
scopeId: scope.id,
|
|
1083
|
+
vertical: scope.vertical,
|
|
1084
|
+
version: versionId,
|
|
1085
|
+
type: row.type,
|
|
1086
|
+
schemaVersion: row.schemaVersion,
|
|
1087
|
+
incoming: hit.incoming,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
847
1090
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1091
|
+
return out.sort((a, b) => a.tenantId.localeCompare(b.tenantId) || a.scopeId.localeCompare(b.scopeId) || a.type.localeCompare(b.type));
|
|
1092
|
+
}
|
|
1093
|
+
/** How `exportBreakRefusal` begins: what lets the promote route recognise it and add the listing. */
|
|
1094
|
+
export const EXPORT_BREAK_REFUSAL = 'promotion drops or re-versions';
|
|
1095
|
+
/** Whether a throw is the promote gate's export-break refusal (`exportBreakRefusal`). */
|
|
1096
|
+
export function isExportBreakRefusal(err) {
|
|
1097
|
+
return errorCodeOf(err) === 'precondition_failed' && err instanceof Error && err.message.startsWith(EXPORT_BREAK_REFUSAL);
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* The refusal a promote gets for a non-empty `exportBreaksOf` (#1705 PR 3). Counts only: the
|
|
1101
|
+
* adapters do not know who is promoting, and a builder must not learn from an error which other
|
|
1102
|
+
* tenants import its events. The route that knows the caller lists what they may see.
|
|
1103
|
+
*/
|
|
1104
|
+
export function exportBreakRefusal(breaks) {
|
|
1105
|
+
const types = new Set(breaks.map((b) => b.type)).size;
|
|
1106
|
+
const apps = new Set(breaks.map((b) => b.scopeId)).size;
|
|
1107
|
+
const tenants = new Set(breaks.map((b) => b.tenantId)).size;
|
|
1108
|
+
return (`${EXPORT_BREAK_REFUSAL} ${types} exported event type(s) that ${apps} installed app(s) in ` +
|
|
1109
|
+
`${tenants} tenant(s) import — their edges would stop delivering it. Acknowledge it explicitly ` +
|
|
1110
|
+
`(exportBreak) to promote, or keep the export and bump its consumers first`);
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* The two ends of the edge from `from` into `consumer`, by #1706's one rule
|
|
1114
|
+
* (`resolveVerticalInstanceFrom`: same tenant, active, primary; two are `ambiguous`, refused rather
|
|
1115
|
+
* than guessed). The producer is who is read. The consumer is who the producer's grant names, and a
|
|
1116
|
+
* grant naming a slug cannot tell two installs of it apart. Shared by the sweep and the health read,
|
|
1117
|
+
* so the view cannot disagree with the next pass about where an edge points, or why it points
|
|
1118
|
+
* nowhere.
|
|
1119
|
+
*/
|
|
1120
|
+
function edgeEnds(scopes, consumer, from) {
|
|
1121
|
+
const vertical = consumer.vertical;
|
|
1122
|
+
if (from === vertical)
|
|
1123
|
+
return { reason: `'${vertical}' imports from itself — an import names ANOTHER vertical` };
|
|
852
1124
|
const self = resolveVerticalInstanceFrom(scopes, consumer.tenantId, vertical);
|
|
853
1125
|
if (self.outcome !== 'resolved' || self.instance.scopeId !== consumer.id) {
|
|
854
|
-
return
|
|
855
|
-
state: 'unresolved',
|
|
1126
|
+
return {
|
|
856
1127
|
reason: `this tenant has more than one primary instance of '${vertical}', so the producer cannot tell which one its grant names`,
|
|
857
|
-
}
|
|
1128
|
+
};
|
|
858
1129
|
}
|
|
859
1130
|
const producer = resolveVerticalInstanceFrom(scopes, consumer.tenantId, from);
|
|
860
1131
|
if (producer.outcome !== 'resolved') {
|
|
861
|
-
return
|
|
862
|
-
state: 'unresolved',
|
|
1132
|
+
return {
|
|
863
1133
|
reason: producer.outcome === 'not-installed'
|
|
864
1134
|
? `'${from}' is not installed in this tenant`
|
|
865
1135
|
: `this tenant has more than one primary instance of '${from}' — delivery waits until one is chosen`,
|
|
866
|
-
}
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
return { source: { vertical: from, scopeId: producer.instance.scopeId } };
|
|
1139
|
+
}
|
|
1140
|
+
/** A consumer's declared imports, grouped by the vertical they come from. */
|
|
1141
|
+
function wantsBySource(consumes, only = () => true) {
|
|
1142
|
+
const bySource = new Map();
|
|
1143
|
+
for (const c of consumes) {
|
|
1144
|
+
if (!only(c.from))
|
|
1145
|
+
continue;
|
|
1146
|
+
const list = bySource.get(c.from) ?? [];
|
|
1147
|
+
list.push({ type: c.type, schemaVersion: c.schemaVersion });
|
|
1148
|
+
bySource.set(c.from, list);
|
|
867
1149
|
}
|
|
868
|
-
|
|
1150
|
+
return bySource;
|
|
1151
|
+
}
|
|
1152
|
+
async function sweepEdge(reach, scopes, consumer, from, wants, state, budget) {
|
|
1153
|
+
const vertical = consumer.vertical;
|
|
1154
|
+
const edge = (rest) => ({
|
|
1155
|
+
tenantId: consumer.tenantId,
|
|
1156
|
+
consumer: { scopeId: consumer.id, vertical },
|
|
1157
|
+
producer: { vertical: from, scopeId: null },
|
|
1158
|
+
delivered: 0,
|
|
1159
|
+
deadLettered: 0,
|
|
1160
|
+
withheld: 0,
|
|
1161
|
+
duplicates: 0,
|
|
1162
|
+
...rest,
|
|
1163
|
+
});
|
|
1164
|
+
const ends = edgeEnds(scopes, consumer, from);
|
|
1165
|
+
if ('reason' in ends)
|
|
1166
|
+
return edge({ state: 'unresolved', reason: ends.reason });
|
|
1167
|
+
const { source } = ends;
|
|
869
1168
|
const at = { producer: source };
|
|
870
1169
|
const after = state.cursors.find((c) => c.source === source.scopeId)?.cursor ?? null;
|
|
871
1170
|
try {
|
|
872
|
-
const batch = await reach.readExports(
|
|
1171
|
+
const batch = await reach.readExports(consumer.tenantId, source.scopeId, {
|
|
873
1172
|
consumer: vertical,
|
|
874
1173
|
after,
|
|
875
1174
|
wants,
|
|
@@ -921,6 +1220,236 @@ async function sweepEdge(reach, scopes, consumer, from, wants, state, budget) {
|
|
|
921
1220
|
return edge({ ...at, state: 'failed', reason: message(err) });
|
|
922
1221
|
}
|
|
923
1222
|
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Where every cross-vertical edge of one tenant stands, read LIVE (#1705 PR 3): the view behind
|
|
1225
|
+
* the console's and the dashboard's edge health.
|
|
1226
|
+
*
|
|
1227
|
+
* It is the sweep's own walk with the delivery taken out. It uses the same reach, the same
|
|
1228
|
+
* narrowing (`candidates`, which keeps it off scopes that import nothing), and the same resolution
|
|
1229
|
+
* of both ends (`edgeEnds`). So it cannot disagree with the next pass about which edges exist or
|
|
1230
|
+
* where they point. Per edge it makes one probe read of the producer after the watermark,
|
|
1231
|
+
* `limit: 1`, which tells behind from caught up and dates the oldest waiting event. That read is the
|
|
1232
|
+
* producer's own `readExportedEvents`, gated and audited as on a pass, and nothing it returns is
|
|
1233
|
+
* delivered or kept. The consumer's door is read from its peer switch (`peerGrantsStatus`), because
|
|
1234
|
+
* a consumer refusing the producer is visible only at delivery otherwise. The tenant's recent
|
|
1235
|
+
* `vertical-events` sweep-run rows add the last pass that delivered and the last that did not.
|
|
1236
|
+
*
|
|
1237
|
+
* `focus` narrows it to one scope's edges, the ones into it and out of it: a per-app view asks
|
|
1238
|
+
* nothing about the rest of the tenant. Out of it means consumers the narrowing names for the
|
|
1239
|
+
* focus's vertical (`hint.from`), so the registry, not a scope, decides who is asked.
|
|
1240
|
+
*
|
|
1241
|
+
* Cost per view: one directory read, the narrowing, then per importing scope one `importState`
|
|
1242
|
+
* and one door read, per edge one probe read and one or two sweep-run reads. It is bounded by the
|
|
1243
|
+
* tenant, never the fleet.
|
|
1244
|
+
*
|
|
1245
|
+
* Who the reads are audited as: the directory, door and history reads as `actor`, the viewer the
|
|
1246
|
+
* caller names. The reach's reads (`importState`, the probe) are audited as whatever actor the
|
|
1247
|
+
* reach was built with. On the control plane that is the sweep's own actor, since it is the
|
|
1248
|
+
* sweep's reach, so those rows read as the platform's, not the viewer's.
|
|
1249
|
+
*
|
|
1250
|
+
* `unavailable` is its own state: a side that could not be asked gets no health, and no
|
|
1251
|
+
* surface may show it as healthy.
|
|
1252
|
+
*/
|
|
1253
|
+
export async function crossVerticalHealth(host, options) {
|
|
1254
|
+
const { actor, tenantId } = options;
|
|
1255
|
+
const now = options.now ?? Date.now;
|
|
1256
|
+
const concurrency = options.concurrency ?? 8;
|
|
1257
|
+
const report = {
|
|
1258
|
+
tenantId,
|
|
1259
|
+
checkedAt: new Date(now()).toISOString(),
|
|
1260
|
+
edges: [],
|
|
1261
|
+
unavailable: null,
|
|
1262
|
+
history: { available: true, reason: null },
|
|
1263
|
+
};
|
|
1264
|
+
const cv = options.crossVertical ?? {};
|
|
1265
|
+
// No reach, on a host whose apps live in their own deployments (the shared control plane without
|
|
1266
|
+
// DISPATCH): this host's own verbs cannot see a single hosted scope, and its registered modules
|
|
1267
|
+
// import nothing, so the default would report "no edges". That is an answer it cannot give.
|
|
1268
|
+
if (!cv.reach && host.servesScopesElsewhere?.()) {
|
|
1269
|
+
report.unavailable =
|
|
1270
|
+
"edge health cannot reach the deployments that serve this tenant's apps: no cross-vertical reach is " +
|
|
1271
|
+
'configured on this control plane (DISPATCH / PLATFORM_SECRET)';
|
|
1272
|
+
return report;
|
|
1273
|
+
}
|
|
1274
|
+
const reach = cv.reach ?? {
|
|
1275
|
+
importState: (t, s) => host.admin.importState(actor, t, s),
|
|
1276
|
+
readExports: (t, s, input) => host.admin.readExportedEvents(actor, t, s, input),
|
|
1277
|
+
deliver: () => Promise.reject(new Error('edge health never delivers')),
|
|
1278
|
+
};
|
|
1279
|
+
const candidatesOf = reach.candidates?.bind(reach) ??
|
|
1280
|
+
((scopes) => ((host.registeredImports?.() ?? []).length > 0 ? scopes : []));
|
|
1281
|
+
const doorOf = options.door ?? ((t, s) => host.admin.peerGrantsStatus(actor, { tenantId: t, scopeId: s }));
|
|
1282
|
+
const knownImporters = new Set();
|
|
1283
|
+
const known = (ids) => {
|
|
1284
|
+
for (const id of ids)
|
|
1285
|
+
knownImporters.add(id);
|
|
1286
|
+
};
|
|
1287
|
+
const listed = await (async () => {
|
|
1288
|
+
const scopes = (await host.admin.listScopes(actor, { status: 'active', tenantId })).filter((s) => isPrimaryScope(s) && s.vertical !== null);
|
|
1289
|
+
if (options.focus === undefined)
|
|
1290
|
+
return { scopes, candidates: await candidatesOf(scopes, { known }) };
|
|
1291
|
+
const focus = scopes.find((s) => s.id === options.focus);
|
|
1292
|
+
if (!focus)
|
|
1293
|
+
return { scopes, candidates: [] };
|
|
1294
|
+
const [into, outOf] = await Promise.all([
|
|
1295
|
+
candidatesOf([focus], { known }),
|
|
1296
|
+
candidatesOf(scopes, { from: focus.vertical, known }),
|
|
1297
|
+
]);
|
|
1298
|
+
return { scopes, focus, candidates: [...into, ...outOf.filter((c) => c.id !== focus.id)] };
|
|
1299
|
+
})().then((v) => ({ ok: true, ...v }), (err) => ({ ok: false, error: message(err) }));
|
|
1300
|
+
if (!listed.ok) {
|
|
1301
|
+
report.unavailable = `could not list this tenant's apps that import events: ${listed.error}`;
|
|
1302
|
+
return report;
|
|
1303
|
+
}
|
|
1304
|
+
const { scopes, candidates } = listed;
|
|
1305
|
+
const focusScope = 'focus' in listed ? listed.focus : undefined;
|
|
1306
|
+
const focusVertical = focusScope?.vertical;
|
|
1307
|
+
// Which sweep-run unit each edge's history is filed under: `<consumer>:<producer vertical>`,
|
|
1308
|
+
// or `<consumer>:*` for a consumer that could not be asked (whatever the view tags it with).
|
|
1309
|
+
const unitOf = new Map();
|
|
1310
|
+
await mapBounded(candidates, concurrency, async (consumer) => {
|
|
1311
|
+
const vertical = consumer.vertical ?? '';
|
|
1312
|
+
const edge = (from, rest) => ({
|
|
1313
|
+
tenantId,
|
|
1314
|
+
consumer: { scopeId: consumer.id, vertical },
|
|
1315
|
+
producer: { vertical: from, scopeId: null },
|
|
1316
|
+
reason: null,
|
|
1317
|
+
watermark: null,
|
|
1318
|
+
oldestPending: null,
|
|
1319
|
+
lagMs: null,
|
|
1320
|
+
unexported: [],
|
|
1321
|
+
lastDelivered: null,
|
|
1322
|
+
lastProblem: null,
|
|
1323
|
+
...rest,
|
|
1324
|
+
});
|
|
1325
|
+
const filed = (e, unitFrom) => {
|
|
1326
|
+
unitOf.set(e, `${consumer.id}:${unitFrom}`);
|
|
1327
|
+
return e;
|
|
1328
|
+
};
|
|
1329
|
+
// Into the focus, every source. Into anyone else, only the focus's own vertical.
|
|
1330
|
+
const wanted = (from) => focusVertical === undefined || consumer.id === options.focus || from === focusVertical;
|
|
1331
|
+
// The consumer's imports and its door, together: the door read is wasted only on a consumer
|
|
1332
|
+
// that turns out to import nothing, which the narrowing has already made rare.
|
|
1333
|
+
const [stateRead, doorRead] = await Promise.all([
|
|
1334
|
+
reach.importState(tenantId, consumer.id).then((state) => ({ ok: true, state }), (err) => ({ ok: false, error: message(err) })),
|
|
1335
|
+
doorOf(tenantId, consumer.id).then((entries) => ({ ok: true, entries }), (err) => ({ ok: false, error: message(err) })),
|
|
1336
|
+
]);
|
|
1337
|
+
// A consumer that could not be asked names no producer ('*'). Seen from a focused PRODUCER,
|
|
1338
|
+
// though, it is one of that producer's consumers (the narrowing named it for the focus's
|
|
1339
|
+
// vertical), so it is tagged with the focus: a view of the producer must show the failure
|
|
1340
|
+
// rather than filter out an edge whose other end is unknown.
|
|
1341
|
+
const unasked = (reason) => filed(focusScope && consumer.id !== focusScope.id
|
|
1342
|
+
? edge(focusScope.vertical, { state: 'unavailable', reason, producer: { vertical: focusScope.vertical, scopeId: focusScope.id } })
|
|
1343
|
+
: edge('*', { state: 'unavailable', reason }), '*');
|
|
1344
|
+
if (!stateRead.ok) {
|
|
1345
|
+
report.edges.push(unasked(`could not ask this app what it imports: ${stateRead.error}`));
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
const state = stateRead.state;
|
|
1349
|
+
if (state.consumes.length === 0) {
|
|
1350
|
+
if (knownImporters.has(consumer.id)) {
|
|
1351
|
+
report.edges.push(unasked("the version registry says this app's code imports events, but its deployment answers that it " +
|
|
1352
|
+
'imports nothing — it is not running the version the registry names; redeploy or reconcile it'));
|
|
1353
|
+
}
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
// Unreadable is not "open": the edge keeps its probe's state, and the reason says so.
|
|
1357
|
+
const door = doorRead.ok
|
|
1358
|
+
? new Map(doorRead.entries.map((e) => [e.vertical, { calls: e.calls, reason: e.switchedOff?.reason ?? null }]))
|
|
1359
|
+
: null;
|
|
1360
|
+
const sources = [...wantsBySource(state.consumes, wanted)];
|
|
1361
|
+
await mapBounded(sources, concurrency, async ([from, wants]) => {
|
|
1362
|
+
report.edges.push(filed(await edgeHealthOf(from, wants), from));
|
|
1363
|
+
});
|
|
1364
|
+
async function edgeHealthOf(from, wants) {
|
|
1365
|
+
const ends = edgeEnds(scopes, consumer, from);
|
|
1366
|
+
if ('reason' in ends)
|
|
1367
|
+
return edge(from, { state: 'unresolved', reason: ends.reason });
|
|
1368
|
+
const { source } = ends;
|
|
1369
|
+
const cursor = state.cursors.find((c) => c.source === source.scopeId) ?? null;
|
|
1370
|
+
const at = {
|
|
1371
|
+
producer: source,
|
|
1372
|
+
watermark: cursor?.cursor ? { cursor: cursor.cursor, updatedAt: cursor.updatedAt } : null,
|
|
1373
|
+
};
|
|
1374
|
+
let probe;
|
|
1375
|
+
try {
|
|
1376
|
+
probe = await reach.readExports(tenantId, source.scopeId, {
|
|
1377
|
+
consumer: vertical,
|
|
1378
|
+
after: cursor?.cursor ?? null,
|
|
1379
|
+
wants,
|
|
1380
|
+
limit: 1,
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
catch (err) {
|
|
1384
|
+
return edge(from, { ...at, state: 'unavailable', reason: `could not ask '${from}' what is waiting: ${message(err)}` });
|
|
1385
|
+
}
|
|
1386
|
+
const unexported = probe.unexported;
|
|
1387
|
+
if (probe.paused) {
|
|
1388
|
+
return edge(from, {
|
|
1389
|
+
...at,
|
|
1390
|
+
unexported,
|
|
1391
|
+
state: 'paused',
|
|
1392
|
+
reason: `'${from}' does not grant vertical:${vertical} ${probe.paused.missing.join(', ')} — nothing moves; the backlog waits in its outbox`,
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
const first = [...probe.events, ...probe.withheld].sort((a, b) => (a.id < b.id ? -1 : 1))[0];
|
|
1396
|
+
const oldestPending = first ? { id: first.id, occurredAt: first.occurredAt } : null;
|
|
1397
|
+
const occurred = first ? Date.parse(first.occurredAt) : NaN;
|
|
1398
|
+
const lagMs = first && Number.isFinite(occurred) ? Math.max(0, Math.floor(now() - occurred)) : null;
|
|
1399
|
+
const pending = { ...at, unexported, oldestPending, lagMs };
|
|
1400
|
+
const gate = door?.get(from);
|
|
1401
|
+
if (gate && gate.calls !== 'on') {
|
|
1402
|
+
return edge(from, {
|
|
1403
|
+
...pending,
|
|
1404
|
+
state: 'paused',
|
|
1405
|
+
reason: gate.calls === 'off'
|
|
1406
|
+
? `this app has '${from}' switched off${gate.reason ? ` (${gate.reason})` : ''} — nothing is delivered; the backlog waits in the producer's outbox`
|
|
1407
|
+
: `this app holds no grant for '${from}' — its door refuses the delivery; the backlog waits in the producer's outbox`,
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
// Caught up means nothing is waiting, which an unread door cannot change, so its reason is
|
|
1411
|
+
// null as the contract says. Behind with an unread door says so: whether the next pass
|
|
1412
|
+
// delivers depends on it.
|
|
1413
|
+
if (!first)
|
|
1414
|
+
return edge(from, { ...pending, state: 'caught-up', reason: null });
|
|
1415
|
+
const doorNote = doorRead.ok ? '' : ` (whether this app admits '${from}' could not be read: ${doorRead.error})`;
|
|
1416
|
+
return edge(from, {
|
|
1417
|
+
...pending,
|
|
1418
|
+
state: 'behind',
|
|
1419
|
+
reason: `events are waiting past the watermark; the next pass takes them${doorNote}`,
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
});
|
|
1423
|
+
// The history, read per edge: the last pass that delivered, and the last that did not when it is
|
|
1424
|
+
// newer. Per edge rather than one window over the tenant, so a noisy edge's rows cannot push a
|
|
1425
|
+
// quiet edge's last delivery out of the read. One read when the newest row is a delivery, two
|
|
1426
|
+
// otherwise. A failed read costs the history, never the live state.
|
|
1427
|
+
await mapBounded(report.edges, concurrency, async (e) => {
|
|
1428
|
+
const unit = unitOf.get(e);
|
|
1429
|
+
if (unit === undefined)
|
|
1430
|
+
return;
|
|
1431
|
+
try {
|
|
1432
|
+
const runs = (filter) => host.admin.listSweepRuns(actor, { kind: 'vertical-events', tenantId, unit, limit: 1, ...filter });
|
|
1433
|
+
const [latest] = await runs({});
|
|
1434
|
+
if (!latest)
|
|
1435
|
+
return;
|
|
1436
|
+
if (latest.outcome === 'ok') {
|
|
1437
|
+
e.lastDelivered = { at: latest.at };
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
const [ok] = await runs({ outcome: 'ok' });
|
|
1441
|
+
e.lastDelivered = ok ? { at: ok.at } : null;
|
|
1442
|
+
e.lastProblem = { at: latest.at, outcome: latest.outcome, error: latest.error ?? null };
|
|
1443
|
+
}
|
|
1444
|
+
catch (err) {
|
|
1445
|
+
report.history = { available: false, reason: `the sweep history could not be read: ${message(err)}` };
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
report.edges.sort((a, b) => a.consumer.vertical.localeCompare(b.consumer.vertical) ||
|
|
1449
|
+
a.consumer.scopeId.localeCompare(b.consumer.scopeId) ||
|
|
1450
|
+
a.producer.vertical.localeCompare(b.producer.vertical));
|
|
1451
|
+
return report;
|
|
1452
|
+
}
|
|
924
1453
|
/**
|
|
925
1454
|
* One read→ship→stamp cycle over ONE scope's outbox (#1334). Returns how many
|
|
926
1455
|
* events shipped, so the caller can tell a full batch (more waiting) from a
|