proschi 0.3.0 → 0.5.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 +9 -0
- package/dist/cli.cjs +1507 -245
- package/dist/render.cjs +176 -176
- package/dist/server.cjs +787 -191
- package/grammar/proschi.tmLanguage.json +15 -3
- package/package.json +1 -1
- package/schema/proschi-diagram.schema.json +203 -1
package/dist/server.cjs
CHANGED
|
@@ -25435,7 +25435,7 @@ var IDENT_PART = /[A-Za-z0-9_]/;
|
|
|
25435
25435
|
var TEAM_PART = /[A-Za-z0-9_-]/;
|
|
25436
25436
|
var DIGIT = /[0-9]/;
|
|
25437
25437
|
var LETTER = /[A-Za-z]/;
|
|
25438
|
-
var SEPARATE_UNIT = /^[ \t]+(rps|rpm|rpd|ms|s|%|usd\/month)(?![A-Za-z0-9_/])/;
|
|
25438
|
+
var SEPARATE_UNIT = /^[ \t]+(rps|rpm|rpd|ms|s|%|usd\/month|usd\/GB|MB\/s|GB\/s)(?![A-Za-z0-9_/])/;
|
|
25439
25439
|
function tokenizeLine(text, line) {
|
|
25440
25440
|
const tokens = [];
|
|
25441
25441
|
const diagnostics = [];
|
|
@@ -25627,11 +25627,21 @@ function endpointGroupKey(endpoint, all) {
|
|
|
25627
25627
|
}
|
|
25628
25628
|
|
|
25629
25629
|
// ../frontend/src/dsl/kinds.ts
|
|
25630
|
-
var KINDS = ["client", "edge", "service", "function", "cache", "database", "search", "analytics", "queue", "storage", "external", "other"];
|
|
25630
|
+
var KINDS = ["client", "edge", "cdn", "loadbalancer", "gateway", "dns", "service", "function", "cache", "database", "search", "analytics", "queue", "storage", "external", "other"];
|
|
25631
25631
|
var DATA_STORE_KINDS = ["cache", "database", "search", "analytics", "queue", "storage"];
|
|
25632
|
+
var EDGE_KINDS = ["edge", "cdn", "loadbalancer", "gateway", "dns"];
|
|
25632
25633
|
var BY_TECH = {
|
|
25633
|
-
"AWS
|
|
25634
|
-
"Azure
|
|
25634
|
+
"AWS CloudFront": "cdn",
|
|
25635
|
+
"Azure CDN": "cdn",
|
|
25636
|
+
"GCP Cloud CDN": "cdn",
|
|
25637
|
+
"Azure Front Door": "cdn",
|
|
25638
|
+
"AWS Load Balancer": "loadbalancer",
|
|
25639
|
+
"GCP Load Balancing": "loadbalancer",
|
|
25640
|
+
"AWS API Gateway": "gateway",
|
|
25641
|
+
"Azure API Management": "gateway",
|
|
25642
|
+
"AWS Route53": "dns",
|
|
25643
|
+
"Azure DNS": "dns",
|
|
25644
|
+
"GCP Cloud DNS": "dns",
|
|
25635
25645
|
Redis: "cache",
|
|
25636
25646
|
Elasticsearch: "search",
|
|
25637
25647
|
"GCP BigQuery": "analytics",
|
|
@@ -25663,6 +25673,12 @@ function isKind(word) {
|
|
|
25663
25673
|
function isDataStore(kind) {
|
|
25664
25674
|
return DATA_STORE_KINDS.includes(kind);
|
|
25665
25675
|
}
|
|
25676
|
+
function isEdge(kind) {
|
|
25677
|
+
return EDGE_KINDS.includes(kind);
|
|
25678
|
+
}
|
|
25679
|
+
function kindMatches(actual, wanted) {
|
|
25680
|
+
return actual === wanted || wanted === "edge" && isEdge(actual);
|
|
25681
|
+
}
|
|
25666
25682
|
|
|
25667
25683
|
// ../frontend/src/dsl/quantity.ts
|
|
25668
25684
|
function parseQuantity(text) {
|
|
@@ -25674,11 +25690,11 @@ function parseQuantity(text) {
|
|
|
25674
25690
|
if (suffix === "" || unit) return unit ? { value: round(number * unit.factor), unit: unit.unit } : { value: number };
|
|
25675
25691
|
const magnitude = MAGNITUDES.get(suffix[0]);
|
|
25676
25692
|
const rest = suffix.slice(1);
|
|
25677
|
-
const
|
|
25678
|
-
if (magnitude && (rest === "" ||
|
|
25679
|
-
return
|
|
25693
|
+
const after2 = UNITS.get(rest);
|
|
25694
|
+
if (magnitude && (rest === "" || after2)) {
|
|
25695
|
+
return after2 ? { value: round(number * magnitude * after2.factor), unit: after2.unit } : { value: round(number * magnitude) };
|
|
25680
25696
|
}
|
|
25681
|
-
return { error: `Unknown unit '${suffix}' in '${text}'; use rps, rpm, rpd, ms, s,
|
|
25697
|
+
return { error: `Unknown unit '${suffix}' in '${text}'; use rps, rpm, rpd, ms, s, %, usd/month, MB/s, GB/s or usd/GB (with k, m or b for thousands, millions, billions)` };
|
|
25682
25698
|
}
|
|
25683
25699
|
var round = (n) => Number(n.toPrecision(12));
|
|
25684
25700
|
var MAGNITUDES = /* @__PURE__ */ new Map([
|
|
@@ -25693,9 +25709,67 @@ var UNITS = /* @__PURE__ */ new Map([
|
|
|
25693
25709
|
["ms", { unit: "ms", factor: 1 }],
|
|
25694
25710
|
["s", { unit: "ms", factor: 1e3 }],
|
|
25695
25711
|
["%", { unit: "%", factor: 1 }],
|
|
25696
|
-
["usd/month", { unit: "usd/month", factor: 1 }]
|
|
25712
|
+
["usd/month", { unit: "usd/month", factor: 1 }],
|
|
25713
|
+
["MB/s", { unit: "MBps", factor: 1 }],
|
|
25714
|
+
["GB/s", { unit: "MBps", factor: 1e3 }],
|
|
25715
|
+
["usd/GB", { unit: "usd/GB", factor: 1 }]
|
|
25697
25716
|
]);
|
|
25698
25717
|
|
|
25718
|
+
// ../frontend/src/dsl/access.ts
|
|
25719
|
+
var WRITE_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
|
|
25720
|
+
var WRITE_VERBS = [
|
|
25721
|
+
"INSERT",
|
|
25722
|
+
"UPDATE",
|
|
25723
|
+
"UPSERT",
|
|
25724
|
+
"DELETE",
|
|
25725
|
+
"PUT",
|
|
25726
|
+
"SET",
|
|
25727
|
+
"WRITE",
|
|
25728
|
+
"APPEND",
|
|
25729
|
+
"INCR",
|
|
25730
|
+
"DECR",
|
|
25731
|
+
"LPUSH",
|
|
25732
|
+
"RPUSH",
|
|
25733
|
+
"ZADD",
|
|
25734
|
+
"HSET",
|
|
25735
|
+
"GEOADD",
|
|
25736
|
+
"PUBLISH",
|
|
25737
|
+
"SEND",
|
|
25738
|
+
"ENQUEUE",
|
|
25739
|
+
"PRODUCE",
|
|
25740
|
+
"CHARGE",
|
|
25741
|
+
"CREATE",
|
|
25742
|
+
// DynamoDB and Redis commands that store data
|
|
25743
|
+
"PUTITEM",
|
|
25744
|
+
"UPDATEITEM",
|
|
25745
|
+
"DELETEITEM",
|
|
25746
|
+
"BATCHWRITEITEM",
|
|
25747
|
+
"INCRBY",
|
|
25748
|
+
"HINCRBY",
|
|
25749
|
+
"SADD",
|
|
25750
|
+
"XADD",
|
|
25751
|
+
"MSET",
|
|
25752
|
+
// Plain words for storing something
|
|
25753
|
+
"SAVE",
|
|
25754
|
+
"STORE",
|
|
25755
|
+
"UPLOAD",
|
|
25756
|
+
"COMMIT",
|
|
25757
|
+
"RECORD",
|
|
25758
|
+
"MARK",
|
|
25759
|
+
// Claiming a resource changes state: a seat, a driver, stock
|
|
25760
|
+
"RESERVE",
|
|
25761
|
+
"HOLD",
|
|
25762
|
+
"BOOK",
|
|
25763
|
+
// Handing something on: a write at the receiver (a queue, a provider), like SEND and PUBLISH
|
|
25764
|
+
"EMIT",
|
|
25765
|
+
"NOTIFY"
|
|
25766
|
+
];
|
|
25767
|
+
function accessOf(httpMethod, label) {
|
|
25768
|
+
if (httpMethod) return WRITE_METHODS.includes(httpMethod.toUpperCase()) ? "write" : "read";
|
|
25769
|
+
const word = /^\s*"?([A-Za-z_]+)/.exec(label)?.[1];
|
|
25770
|
+
return word && WRITE_VERBS.includes(word.toUpperCase()) ? "write" : "read";
|
|
25771
|
+
}
|
|
25772
|
+
|
|
25699
25773
|
// ../frontend/src/dsl/parser.ts
|
|
25700
25774
|
var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
25701
25775
|
var DEFAULT_TECH = "Rectangle";
|
|
@@ -25708,10 +25782,13 @@ var UNIT_EXAMPLES = {
|
|
|
25708
25782
|
rps: "a rate, e.g. 100k rps, 6k rpm or 1m rpd",
|
|
25709
25783
|
ms: "a duration, e.g. 50ms or 1.5s",
|
|
25710
25784
|
"%": "a percentage, e.g. 99.9%",
|
|
25711
|
-
"usd/month": "a monthly cost, e.g. 400 usd/month"
|
|
25785
|
+
"usd/month": "a monthly cost, e.g. 400 usd/month",
|
|
25786
|
+
MBps: "a bandwidth, e.g. 500 MB/s or 1 GB/s",
|
|
25787
|
+
"usd/GB": "a price per gigabyte, e.g. 0.05 usd/GB"
|
|
25712
25788
|
};
|
|
25713
|
-
var
|
|
25714
|
-
var
|
|
25789
|
+
var CAPACITY_PARTS = "a rate, reads, writes, shards, latency, availability, cost, durable or volatile, consistency, bandwidth or egress";
|
|
25790
|
+
var ASSERTION_HELP = 'Expected an assertion: "Use case" calls <node>, "Use case" writes <node> before responding, <node> calls <node>, no path from <node> to <node>, <node> has replicas >= 2, \u2026';
|
|
25791
|
+
var ASSERTION_VERBS = "calls, every scenario calls, never calls, never waits for, writes, responds, starts at, has scenario or handles failure of";
|
|
25715
25792
|
var techByName = new Map(
|
|
25716
25793
|
componentCatalog.map((c) => [c.techStack.toLowerCase(), { type: c.type, techStack: c.techStack }])
|
|
25717
25794
|
);
|
|
@@ -26142,7 +26219,20 @@ var Parser = class {
|
|
|
26142
26219
|
if (top?.kind === "usecase" || top?.kind === "alt" || top?.kind === "par") {
|
|
26143
26220
|
const body = this.currentBody();
|
|
26144
26221
|
body.openAlt = void 0;
|
|
26145
|
-
|
|
26222
|
+
const prefixes = arrow === "-->" ? { rest: label } : this.readLabelPrefixes(label, labelToken, line);
|
|
26223
|
+
body.items.push({
|
|
26224
|
+
kind: "step",
|
|
26225
|
+
step: {
|
|
26226
|
+
from,
|
|
26227
|
+
to,
|
|
26228
|
+
arrow,
|
|
26229
|
+
label: prefixes.rest,
|
|
26230
|
+
parallelGroup: top.kind === "par" ? top.group : void 0,
|
|
26231
|
+
...prefixes.multiplier !== void 0 ? { multiplier: prefixes.multiplier } : {},
|
|
26232
|
+
...prefixes.sizeBytes !== void 0 ? { sizeBytes: prefixes.sizeBytes } : {},
|
|
26233
|
+
loc
|
|
26234
|
+
}
|
|
26235
|
+
});
|
|
26146
26236
|
} else if (arrow === "-x") {
|
|
26147
26237
|
this.error("'-x' marks a failed call and is only allowed in use case steps", this.locOf(arrowToken, line));
|
|
26148
26238
|
} else {
|
|
@@ -26152,6 +26242,42 @@ var Parser = class {
|
|
|
26152
26242
|
}
|
|
26153
26243
|
return lastLine;
|
|
26154
26244
|
}
|
|
26245
|
+
/**
|
|
26246
|
+
* Reads `x<N>` (fan-out) and `~<size>` (payload size) at the start of a step
|
|
26247
|
+
* label, in either order (§7.3), and returns the label without them. A
|
|
26248
|
+
* prefix must be followed by whitespace or end the label, so `xml …` and
|
|
26249
|
+
* `x-request-id` are ordinary labels. Problems are reported at the prefix.
|
|
26250
|
+
*/
|
|
26251
|
+
readLabelPrefixes(label, token, line) {
|
|
26252
|
+
const out = { rest: label };
|
|
26253
|
+
if (!token) return out;
|
|
26254
|
+
const after2 = this.lines[line - 1].slice(token.col);
|
|
26255
|
+
const base = token.col + 1 + (after2.length - after2.trimStart().length);
|
|
26256
|
+
let offset = 0;
|
|
26257
|
+
for (; ; ) {
|
|
26258
|
+
const rest = label.slice(offset);
|
|
26259
|
+
const at = (length) => ({ line, col: base + offset, length, ...this.fileField() });
|
|
26260
|
+
const fanOut = /^x(\d+)(?=\s|$)/.exec(rest);
|
|
26261
|
+
const size = rest.startsWith("~") ? /^~(\S*)/.exec(rest) : null;
|
|
26262
|
+
const match = fanOut ?? size;
|
|
26263
|
+
if (!match) break;
|
|
26264
|
+
if (fanOut) {
|
|
26265
|
+
const n = Number(fanOut[1]);
|
|
26266
|
+
if (out.multiplier !== void 0) this.error("The fan-out is given twice; write one x<N>", at(match[0].length));
|
|
26267
|
+
else if (n < 1) this.error("A fan-out needs at least one call per request: x1, x2, \u2026", at(match[0].length));
|
|
26268
|
+
else out.multiplier = n;
|
|
26269
|
+
} else {
|
|
26270
|
+
const bytes = parseSize(size[1]);
|
|
26271
|
+
if (out.sizeBytes !== void 0) this.error("The payload size is given twice; write one ~<size>", at(match[0].length));
|
|
26272
|
+
else if ("error" in bytes) this.error(bytes.error, at(match[0].length));
|
|
26273
|
+
else out.sizeBytes = bytes.value;
|
|
26274
|
+
}
|
|
26275
|
+
offset += match[0].length;
|
|
26276
|
+
offset += label.slice(offset).length - label.slice(offset).trimStart().length;
|
|
26277
|
+
}
|
|
26278
|
+
out.rest = label.slice(offset);
|
|
26279
|
+
return out;
|
|
26280
|
+
}
|
|
26155
26281
|
// ---- HLD sections: traffic, requirements, capacity, entity, decision, test (docs/design/hld-and-practice.md §1) ----
|
|
26156
26282
|
/**
|
|
26157
26283
|
* Starts a section block. Sections are top level only; a misplaced one, or
|
|
@@ -26275,10 +26401,14 @@ var Parser = class {
|
|
|
26275
26401
|
useCase = { name: tokens[i].value, loc: this.locOf(tokens[i], line) };
|
|
26276
26402
|
i++;
|
|
26277
26403
|
};
|
|
26278
|
-
const commit = (requirement2) => {
|
|
26404
|
+
const commit = (requirement2, scenario) => {
|
|
26279
26405
|
this.requirements.push(requirement2);
|
|
26280
26406
|
const named = useCase;
|
|
26281
|
-
if (named)
|
|
26407
|
+
if (named)
|
|
26408
|
+
this.deferred.push(() => {
|
|
26409
|
+
const found = this.checkUseCase(named.name, named.loc);
|
|
26410
|
+
if (found && scenario) this.checkScenario(found, scenario.name, scenario.loc);
|
|
26411
|
+
});
|
|
26282
26412
|
};
|
|
26283
26413
|
const withUseCase = () => useCase ? { useCase: useCase.name } : {};
|
|
26284
26414
|
if (/^p\d+$/.test(word)) {
|
|
@@ -26288,10 +26418,24 @@ var Parser = class {
|
|
|
26288
26418
|
return;
|
|
26289
26419
|
}
|
|
26290
26420
|
readUseCase();
|
|
26421
|
+
let scenario;
|
|
26422
|
+
if (isWord(tokens[i], "scenario")) {
|
|
26423
|
+
const name = tokens[i + 1];
|
|
26424
|
+
if (!useCase) {
|
|
26425
|
+
this.error(`A scenario belongs to a use case; write ${word} "Use case" scenario "Scenario" < 100ms`, this.locOf(tokens[i], line));
|
|
26426
|
+
return;
|
|
26427
|
+
}
|
|
26428
|
+
if (name?.kind !== "string") {
|
|
26429
|
+
this.error("Expected a scenario name in quotes after scenario", this.locOf(name ?? tokens[i], line));
|
|
26430
|
+
return;
|
|
26431
|
+
}
|
|
26432
|
+
scenario = { name: name.value, loc: this.locOf(name, line) };
|
|
26433
|
+
i += 2;
|
|
26434
|
+
}
|
|
26291
26435
|
if (!this.expectOp(tokens, i, ["<", "<="], `${word} "Use case" < 50ms`, line)) return;
|
|
26292
26436
|
const maxMs = this.quantityOf(tokens[i + 1], "ms", tokens[i], line);
|
|
26293
26437
|
if (maxMs === void 0 || !this.expectEnd(tokens, i + 2, line)) return;
|
|
26294
|
-
commit({ kind: "latency", percentile, ...withUseCase(), maxMs, loc });
|
|
26438
|
+
commit({ kind: "latency", percentile, ...withUseCase(), ...scenario ? { scenario: scenario.name } : {}, maxMs, loc }, scenario);
|
|
26295
26439
|
return;
|
|
26296
26440
|
}
|
|
26297
26441
|
switch (word) {
|
|
@@ -26374,8 +26518,50 @@ var Parser = class {
|
|
|
26374
26518
|
part = "durable or volatile";
|
|
26375
26519
|
override.durable = t.value === "durable";
|
|
26376
26520
|
i++;
|
|
26521
|
+
} else if (isWord(t, "reads") || isWord(t, "writes")) {
|
|
26522
|
+
part = t.value;
|
|
26523
|
+
const value = this.quantityOf(tokens[i + 1], "rps", t, line);
|
|
26524
|
+
if (value === void 0) return;
|
|
26525
|
+
if (part === "reads") override.readRps = value;
|
|
26526
|
+
else override.writeRps = value;
|
|
26527
|
+
i += 2;
|
|
26528
|
+
} else if (isWord(t, "shards")) {
|
|
26529
|
+
part = "shards";
|
|
26530
|
+
const n = tokens[i + 1];
|
|
26531
|
+
if (n?.kind !== "number" || Number(n.value) < 1) {
|
|
26532
|
+
this.error("Expected a whole number of shards, at least 1, e.g. shards 4", this.locOf(n ?? t, line));
|
|
26533
|
+
return;
|
|
26534
|
+
}
|
|
26535
|
+
override.shards = Number(n.value);
|
|
26536
|
+
i += 2;
|
|
26537
|
+
} else if (isWord(t, "consistency")) {
|
|
26538
|
+
part = "consistency";
|
|
26539
|
+
const value = tokens[i + 1];
|
|
26540
|
+
if (!isWord(value, "strong") && !isWord(value, "eventual")) {
|
|
26541
|
+
this.error(`Expected strong or eventual after consistency${value ? `, not ${describe(value)}` : ""}`, this.locOf(value ?? t, line));
|
|
26542
|
+
return;
|
|
26543
|
+
}
|
|
26544
|
+
override.consistency = value.value;
|
|
26545
|
+
i += 2;
|
|
26546
|
+
} else if (isWord(t, "bandwidth") || isWord(t, "egress")) {
|
|
26547
|
+
part = t.value;
|
|
26548
|
+
const value = this.quantityOf(tokens[i + 1], part === "bandwidth" ? "MBps" : "usd/GB", t, line);
|
|
26549
|
+
if (value === void 0) return;
|
|
26550
|
+
if (part === "bandwidth") {
|
|
26551
|
+
if (value <= 0) {
|
|
26552
|
+
this.error("Bandwidth must be more than 0", this.locOf(tokens[i + 1], line));
|
|
26553
|
+
return;
|
|
26554
|
+
}
|
|
26555
|
+
override.bandwidthMBps = value;
|
|
26556
|
+
} else override.egressUsdPerGb = value;
|
|
26557
|
+
i += 2;
|
|
26377
26558
|
} else {
|
|
26378
|
-
this.error(`Unexpected ${describe(t)}; expected
|
|
26559
|
+
this.error(`Unexpected ${describe(t)}; expected ${CAPACITY_PARTS}`, this.locOf(t, line));
|
|
26560
|
+
return;
|
|
26561
|
+
}
|
|
26562
|
+
const clash = part === "rate" ? ["reads", "writes"].find((p) => seen.has(p)) : part === "reads" || part === "writes" ? seen.has("rate") ? part : void 0 : void 0;
|
|
26563
|
+
if (clash) {
|
|
26564
|
+
this.error(`A rate sets both reads and writes; give either a rate or reads and writes for '${id.value}'`, this.locOf(t, line));
|
|
26379
26565
|
return;
|
|
26380
26566
|
}
|
|
26381
26567
|
if (seen.has(part)) {
|
|
@@ -26386,7 +26572,7 @@ var Parser = class {
|
|
|
26386
26572
|
}
|
|
26387
26573
|
const idLoc = this.locOf(id, line);
|
|
26388
26574
|
if (seen.size === 0) {
|
|
26389
|
-
this.warning(`Capacity line for '${id.value}' overrides nothing; add
|
|
26575
|
+
this.warning(`Capacity line for '${id.value}' overrides nothing; add ${CAPACITY_PARTS}`, idLoc);
|
|
26390
26576
|
return;
|
|
26391
26577
|
}
|
|
26392
26578
|
const first = this.capacity.find((c) => c.node === id.value);
|
|
@@ -26396,7 +26582,14 @@ var Parser = class {
|
|
|
26396
26582
|
}
|
|
26397
26583
|
this.capacity.push(override);
|
|
26398
26584
|
this.deferred.push(() => {
|
|
26399
|
-
|
|
26585
|
+
const node = this.nodes.get(id.value);
|
|
26586
|
+
if (!node) {
|
|
26587
|
+
this.warning(`Unknown node '${id.value}' in capacity`, idLoc);
|
|
26588
|
+
return;
|
|
26589
|
+
}
|
|
26590
|
+
const storeOnly = ["shards", "consistency"].filter((p) => seen.has(p));
|
|
26591
|
+
if (storeOnly.length && !isDataStore(kindOf(node)))
|
|
26592
|
+
this.warning(`'${id.value}' is not a data store (${DATA_STORE_KINDS.join(", ")}); ${storeOnly.join(" and ")} only apply to data stores`, idLoc);
|
|
26400
26593
|
});
|
|
26401
26594
|
}
|
|
26402
26595
|
/** `entity Url [in db] ["description"] {` */
|
|
@@ -26581,10 +26774,28 @@ var Parser = class {
|
|
|
26581
26774
|
this.error(ASSERTION_HELP, this.locOf(first, line));
|
|
26582
26775
|
return;
|
|
26583
26776
|
}
|
|
26584
|
-
const
|
|
26777
|
+
const inUseCase = isWord(first, "in") && tokens[1]?.kind === "string" ? { name: tokens[1].value, loc: this.locOf(tokens[1], line) } : void 0;
|
|
26778
|
+
const target = selector(inUseCase ? 2 : 0);
|
|
26585
26779
|
if (!target) return;
|
|
26586
|
-
|
|
26587
|
-
|
|
26780
|
+
const word = tokens[target.next];
|
|
26781
|
+
if (isWord(word, "calls") || isWord(word, "never")) {
|
|
26782
|
+
const never = word.value === "never";
|
|
26783
|
+
if (never && !expectWords(target.next + 1, ["calls"], "any service never calls any storage")) return;
|
|
26784
|
+
const to = selector(target.next + (never ? 2 : 1));
|
|
26785
|
+
if (!to) return;
|
|
26786
|
+
const assertion3 = done(
|
|
26787
|
+
{ kind: "senderCalls", ...inUseCase ? { useCase: inUseCase.name } : {}, from: target.selector, to: to.selector, quantifier: never ? "never" : "some", loc },
|
|
26788
|
+
to.next
|
|
26789
|
+
);
|
|
26790
|
+
if (assertion3 && inUseCase) this.deferred.push(() => this.checkUseCase(inUseCase.name, inUseCase.loc));
|
|
26791
|
+
return assertion3;
|
|
26792
|
+
}
|
|
26793
|
+
if (inUseCase) {
|
|
26794
|
+
this.error(`Expected calls or never calls here, e.g. in "${inUseCase.name}" api calls db`, this.locOf(word ?? tokens[tokens.length - 1], line));
|
|
26795
|
+
return;
|
|
26796
|
+
}
|
|
26797
|
+
if (!isWord(word, "has")) {
|
|
26798
|
+
this.error(ASSERTION_HELP, this.locOf(word ?? first, line));
|
|
26588
26799
|
return;
|
|
26589
26800
|
}
|
|
26590
26801
|
const i2 = target.next + 1;
|
|
@@ -26625,6 +26836,10 @@ var Parser = class {
|
|
|
26625
26836
|
const then = selector(target.next + 1);
|
|
26626
26837
|
if (!then) return;
|
|
26627
26838
|
assertion2 = done({ kind: "before", useCase, ...withScenario, first: target.selector, then: then.selector, loc }, then.next);
|
|
26839
|
+
} else if (isWord(tokens[target.next], "after")) {
|
|
26840
|
+
const after2 = selector(target.next + 1);
|
|
26841
|
+
if (!after2) return;
|
|
26842
|
+
assertion2 = done({ kind: "after", useCase, ...withScenario, target: target.selector, after: after2.selector, loc }, after2.next);
|
|
26628
26843
|
} else {
|
|
26629
26844
|
assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "some", loc }, target.next);
|
|
26630
26845
|
}
|
|
@@ -26633,6 +26848,16 @@ var Parser = class {
|
|
|
26633
26848
|
const target = selector(i + 3);
|
|
26634
26849
|
if (!target) return;
|
|
26635
26850
|
assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "every", loc }, target.next);
|
|
26851
|
+
} else if (isWord(verb, "never") && isWord(tokens[i + 1], "waits")) {
|
|
26852
|
+
if (!expectWords(i + 2, ["for"], `"${useCase}" never waits for any queue`)) return;
|
|
26853
|
+
const target = selector(i + 3);
|
|
26854
|
+
if (!target) return;
|
|
26855
|
+
assertion2 = done({ kind: "neverWaits", useCase, ...withScenario, target: target.selector, loc }, target.next);
|
|
26856
|
+
} else if (isWord(verb, "starts")) {
|
|
26857
|
+
if (!wholeUseCase("starts at") || !expectWords(i + 1, ["at"], `"${useCase}" starts at any queue`)) return;
|
|
26858
|
+
const target = selector(i + 2);
|
|
26859
|
+
if (!target) return;
|
|
26860
|
+
assertion2 = done({ kind: "startsAt", useCase, target: target.selector, loc }, target.next);
|
|
26636
26861
|
} else if (isWord(verb, "never")) {
|
|
26637
26862
|
if (!expectWords(i + 1, ["calls"], `"${useCase}" never calls any database`)) return;
|
|
26638
26863
|
const target = selector(i + 2);
|
|
@@ -26674,8 +26899,23 @@ var Parser = class {
|
|
|
26674
26899
|
}
|
|
26675
26900
|
return assertion2;
|
|
26676
26901
|
}
|
|
26677
|
-
/** A node id, `[Tech]
|
|
26902
|
+
/** A selector starting at `tokens[i]`: one or more of node id, `[Tech]`, `any <kind>`, `any strong store`, joined by `or`. */
|
|
26678
26903
|
parseSelector(tokens, i, line) {
|
|
26904
|
+
const atoms = [];
|
|
26905
|
+
let next = i;
|
|
26906
|
+
for (; ; ) {
|
|
26907
|
+
const atom = this.parseSelectorAtom(tokens, next, line);
|
|
26908
|
+
if (!atom) return void 0;
|
|
26909
|
+
atoms.push(atom);
|
|
26910
|
+
next = atom.next;
|
|
26911
|
+
if (!isWord(tokens[next], "or")) break;
|
|
26912
|
+
next++;
|
|
26913
|
+
}
|
|
26914
|
+
const selector = atoms.length === 1 ? atoms[0].selector : { anyOf: atoms.map((a) => a.selector) };
|
|
26915
|
+
return { selector, next, atoms };
|
|
26916
|
+
}
|
|
26917
|
+
/** One node id, `[Tech]`, `any <kind>` or `any strong|eventual store` at `tokens[i]`. */
|
|
26918
|
+
parseSelectorAtom(tokens, i, line) {
|
|
26679
26919
|
const t = tokens[i];
|
|
26680
26920
|
if (t?.kind === "tech") {
|
|
26681
26921
|
const tech = techByName.get(t.value.toLowerCase())?.techStack ?? t.value;
|
|
@@ -26683,8 +26923,15 @@ var Parser = class {
|
|
|
26683
26923
|
}
|
|
26684
26924
|
if (isWord(t, "any")) {
|
|
26685
26925
|
const kind = tokens[i + 1];
|
|
26926
|
+
if (isWord(kind, "strong") || isWord(kind, "eventual")) {
|
|
26927
|
+
if (!isWord(tokens[i + 2], "store")) {
|
|
26928
|
+
this.error(`Expected 'store' here, e.g. any ${kind.value} store`, this.locOf(tokens[i + 2] ?? kind, line));
|
|
26929
|
+
return void 0;
|
|
26930
|
+
}
|
|
26931
|
+
return { selector: { consistency: kind.value }, next: i + 3, token: t, loc: this.locOf(t, line) };
|
|
26932
|
+
}
|
|
26686
26933
|
if (kind?.kind !== "ident" || !isKind(kind.value)) {
|
|
26687
|
-
this.error(`Expected a kind after any: ${KINDS.join(", ")}`, this.locOf(kind ?? t, line));
|
|
26934
|
+
this.error(`Expected a kind after any: ${KINDS.join(", ")}, or strong store / eventual store`, this.locOf(kind ?? t, line));
|
|
26688
26935
|
return void 0;
|
|
26689
26936
|
}
|
|
26690
26937
|
return { selector: { kind: kind.value }, next: i + 2, token: t, loc: this.locOf(t, line) };
|
|
@@ -26694,12 +26941,14 @@ var Parser = class {
|
|
|
26694
26941
|
return void 0;
|
|
26695
26942
|
}
|
|
26696
26943
|
/** Warns about a selector naming a tech stack that does not exist, and (once every file is read) a node that does not. */
|
|
26697
|
-
checkSelectorLater({
|
|
26698
|
-
|
|
26699
|
-
|
|
26700
|
-
|
|
26701
|
-
|
|
26702
|
-
|
|
26944
|
+
checkSelectorLater({ atoms }) {
|
|
26945
|
+
for (const { selector, token, loc } of atoms) {
|
|
26946
|
+
if ("tech" in selector && !techByName.has(token.value.toLowerCase())) this.warning(`Unknown tech stack '${token.value}'`, loc);
|
|
26947
|
+
if ("node" in selector) {
|
|
26948
|
+
this.deferred.push(() => {
|
|
26949
|
+
if (!this.nodes.has(selector.node)) this.warning(`Unknown node '${selector.node}'`, loc);
|
|
26950
|
+
});
|
|
26951
|
+
}
|
|
26703
26952
|
}
|
|
26704
26953
|
}
|
|
26705
26954
|
checkUseCase(name, loc) {
|
|
@@ -26713,10 +26962,10 @@ var Parser = class {
|
|
|
26713
26962
|
this.warning(`Use case '${useCase.name}' has no scenario '${name}'; its scenarios are ${names}`, loc);
|
|
26714
26963
|
}
|
|
26715
26964
|
/** The value of a quantity token in `unit`, or undefined after reporting what is wrong. `after` locates a missing token. */
|
|
26716
|
-
quantityOf(token, unit,
|
|
26965
|
+
quantityOf(token, unit, after2, line) {
|
|
26717
26966
|
const expected = UNIT_EXAMPLES[unit];
|
|
26718
26967
|
if (token?.kind !== "quantity" && token?.kind !== "number") {
|
|
26719
|
-
this.error(`Expected ${expected}${token ? `, not ${describe(token)}` : ""}`, this.locOf(token ??
|
|
26968
|
+
this.error(`Expected ${expected}${token ? `, not ${describe(token)}` : ""}`, this.locOf(token ?? after2, line));
|
|
26720
26969
|
return void 0;
|
|
26721
26970
|
}
|
|
26722
26971
|
const loc = this.locOf(token, line);
|
|
@@ -26819,7 +27068,10 @@ var Parser = class {
|
|
|
26819
27068
|
isParallel: step.parallelGroup !== void 0,
|
|
26820
27069
|
isConditional: false,
|
|
26821
27070
|
...step.arrow === "-x" ? { failed: true } : {},
|
|
26822
|
-
loc: step.loc
|
|
27071
|
+
loc: step.loc,
|
|
27072
|
+
...step.multiplier !== void 0 ? { multiplier: step.multiplier } : {},
|
|
27073
|
+
...step.sizeBytes !== void 0 ? { sizeBytes: step.sizeBytes } : {},
|
|
27074
|
+
access: accessOf(label.method, step.label)
|
|
26823
27075
|
});
|
|
26824
27076
|
}
|
|
26825
27077
|
return steps;
|
|
@@ -26985,6 +27237,16 @@ function parseStepLabel(input) {
|
|
|
26985
27237
|
const payload = splitPayload(input);
|
|
26986
27238
|
return { name: payload.text, format: payload.body ? payload.format : void 0, body: payload.body };
|
|
26987
27239
|
}
|
|
27240
|
+
var SIZE_UNITS = { B: 1, KB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12 };
|
|
27241
|
+
function parseSize(text) {
|
|
27242
|
+
const m = /^(\d+(?:\.\d+)?)([A-Za-z]*)$/.exec(text);
|
|
27243
|
+
if (!m) return { error: `Expected a payload size after ~, e.g. ~2MB, ~500KB or ~1.5GB${text ? `, not '~${text}'` : ""}` };
|
|
27244
|
+
const factor = SIZE_UNITS[m[2]];
|
|
27245
|
+
if (factor === void 0) return { error: m[2] ? `Unknown size unit '${m[2]}' in '~${text}'; use B, KB, MB, GB or TB` : `'~${text}' needs a unit: B, KB, MB, GB or TB` };
|
|
27246
|
+
const value = Math.round(Number(m[1]) * factor);
|
|
27247
|
+
if (value <= 0) return { error: `A payload size must be more than 0, not '~${text}'` };
|
|
27248
|
+
return { value };
|
|
27249
|
+
}
|
|
26988
27250
|
function inferProtocol(isAsync, method, target) {
|
|
26989
27251
|
if (isAsync || target?.type === "queue") return "MESSAGING";
|
|
26990
27252
|
if (!method) return "OTHER";
|
|
@@ -27215,8 +27477,8 @@ function alignConnections(run) {
|
|
|
27215
27477
|
function commentOf(raw, tokens) {
|
|
27216
27478
|
const last = tokens[tokens.length - 1];
|
|
27217
27479
|
if (last.kind === "label") {
|
|
27218
|
-
const
|
|
27219
|
-
return
|
|
27480
|
+
const after2 = raw.slice(last.col);
|
|
27481
|
+
return after2.slice(stripTrailingComment(after2).length).trim();
|
|
27220
27482
|
}
|
|
27221
27483
|
return raw.slice(last.col - 1 + last.length).trim();
|
|
27222
27484
|
}
|
|
@@ -27384,6 +27646,104 @@ var RESERVED = new Set(
|
|
|
27384
27646
|
].map((w) => w.toLowerCase())
|
|
27385
27647
|
);
|
|
27386
27648
|
|
|
27649
|
+
// ../frontend/src/sim/access.ts
|
|
27650
|
+
function stepAccess(step) {
|
|
27651
|
+
return step.access ?? accessOf(step.httpMethod, step.stepName ?? "");
|
|
27652
|
+
}
|
|
27653
|
+
function accessIn(diagram) {
|
|
27654
|
+
const queues = new Set(diagram.nodes.filter((n) => kindOf(n) === "queue").map((n) => n.id));
|
|
27655
|
+
return (step) => queues.has(step.toServiceId) ? "write" : stepAccess(step);
|
|
27656
|
+
}
|
|
27657
|
+
|
|
27658
|
+
// ../frontend/src/sim/profiles.ts
|
|
27659
|
+
var NEUTRAL = { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false };
|
|
27660
|
+
var RELATIONAL = {
|
|
27661
|
+
rps: 2e4,
|
|
27662
|
+
readRps: 2e4,
|
|
27663
|
+
writeRps: 5e3,
|
|
27664
|
+
writeScaling: "shards",
|
|
27665
|
+
latencyMs: 5,
|
|
27666
|
+
availability: 0.9995,
|
|
27667
|
+
costUsd: 400,
|
|
27668
|
+
durable: true,
|
|
27669
|
+
consistency: "strong"
|
|
27670
|
+
};
|
|
27671
|
+
var KIND_PROFILES = {
|
|
27672
|
+
client: { ...NEUTRAL, bandwidthMBps: 10 },
|
|
27673
|
+
edge: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
|
|
27674
|
+
cdn: { rps: 2e5, latencyMs: 5, availability: 0.9999, costUsd: 100, durable: false, egressUsdPerGb: 0.02 },
|
|
27675
|
+
loadbalancer: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
|
|
27676
|
+
gateway: { rps: 1e4, latencyMs: 10, availability: 0.9995, costUsd: 100, durable: false },
|
|
27677
|
+
// Name resolution happens before the request and is cached: off the request path.
|
|
27678
|
+
dns: { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false },
|
|
27679
|
+
service: { rps: 2e3, latencyMs: 10, availability: 0.995, costUsd: 100, durable: false },
|
|
27680
|
+
function: { rps: 1e4, latencyMs: 25, availability: 0.9995, costUsd: 200, durable: false },
|
|
27681
|
+
cache: { rps: 1e5, latencyMs: 1, availability: 0.999, costUsd: 150, durable: false, consistency: "eventual" },
|
|
27682
|
+
database: RELATIONAL,
|
|
27683
|
+
search: { rps: 3e3, latencyMs: 15, availability: 0.999, costUsd: 400, durable: true, consistency: "eventual" },
|
|
27684
|
+
analytics: { rps: 200, latencyMs: 500, availability: 0.999, costUsd: 300, durable: true, consistency: "eventual" },
|
|
27685
|
+
queue: { rps: 5e4, latencyMs: 5, availability: 0.9999, costUsd: 200, durable: true, consistency: "strong" },
|
|
27686
|
+
storage: { rps: 5e3, latencyMs: 30, availability: 0.9999, costUsd: 50, durable: true, consistency: "eventual", egressUsdPerGb: 0.09 },
|
|
27687
|
+
external: { rps: 1e3, latencyMs: 200, availability: 0.999, costUsd: 0, durable: false },
|
|
27688
|
+
other: NEUTRAL
|
|
27689
|
+
};
|
|
27690
|
+
var NOSQL = { rps: 2e4, latencyMs: 5, availability: 0.9999, costUsd: 500, durable: true, consistency: "eventual" };
|
|
27691
|
+
var NOSQL_STRONG = { ...NOSQL, consistency: "strong" };
|
|
27692
|
+
var TECH_PROFILES = {
|
|
27693
|
+
DynamoDB: NOSQL,
|
|
27694
|
+
"AWS DynamoDB": NOSQL,
|
|
27695
|
+
Cassandra: NOSQL,
|
|
27696
|
+
CouchDB: NOSQL,
|
|
27697
|
+
"Azure Cosmos DB": NOSQL,
|
|
27698
|
+
MongoDB: NOSQL_STRONG,
|
|
27699
|
+
"GCP Bigtable": NOSQL_STRONG,
|
|
27700
|
+
"GCP Firestore": NOSQL_STRONG,
|
|
27701
|
+
"GCP Spanner": NOSQL_STRONG
|
|
27702
|
+
};
|
|
27703
|
+
var BANDWIDTH = {
|
|
27704
|
+
client: 10,
|
|
27705
|
+
edge: 1e3,
|
|
27706
|
+
cdn: 1e3,
|
|
27707
|
+
loadbalancer: 1e3,
|
|
27708
|
+
gateway: 1e3,
|
|
27709
|
+
dns: 1e3,
|
|
27710
|
+
service: 200
|
|
27711
|
+
};
|
|
27712
|
+
var DEFAULT_BANDWIDTH_MBPS = 100;
|
|
27713
|
+
function profileOf(node, override) {
|
|
27714
|
+
const kind = kindOf(node);
|
|
27715
|
+
const base = TECH_PROFILES[node.techStack] ?? KIND_PROFILES[kind];
|
|
27716
|
+
const baseRead = base.readRps ?? base.rps;
|
|
27717
|
+
const baseWrite = base.writeRps ?? base.rps;
|
|
27718
|
+
const readRps = override?.readRps ?? override?.rps ?? baseRead;
|
|
27719
|
+
const writeRps = override?.writeRps ?? override?.rps ?? baseWrite;
|
|
27720
|
+
const consistency = override?.consistency ?? base.consistency;
|
|
27721
|
+
return {
|
|
27722
|
+
kind,
|
|
27723
|
+
rps: readRps,
|
|
27724
|
+
readRps,
|
|
27725
|
+
writeRps,
|
|
27726
|
+
writeScaling: base.writeScaling ?? "replicas",
|
|
27727
|
+
shards: Math.max(1, Math.floor(override?.shards ?? 1)),
|
|
27728
|
+
latencyMs: override?.latencyMs ?? base.latencyMs,
|
|
27729
|
+
availability: override?.availability !== void 0 ? override.availability / 100 : base.availability,
|
|
27730
|
+
costUsd: override?.costUsd ?? base.costUsd,
|
|
27731
|
+
durable: override?.durable ?? base.durable,
|
|
27732
|
+
...consistency && isDataStore(kind) ? { consistency } : {},
|
|
27733
|
+
bandwidthMBps: override?.bandwidthMBps ?? base.bandwidthMBps ?? BANDWIDTH[kind] ?? DEFAULT_BANDWIDTH_MBPS,
|
|
27734
|
+
egressUsdPerGb: override?.egressUsdPerGb ?? base.egressUsdPerGb ?? 0
|
|
27735
|
+
};
|
|
27736
|
+
}
|
|
27737
|
+
function capacityOf(p, replicas) {
|
|
27738
|
+
return {
|
|
27739
|
+
readRps: p.readRps * replicas * p.shards,
|
|
27740
|
+
writeRps: p.writeRps * (p.writeScaling === "shards" ? 1 : replicas) * p.shards
|
|
27741
|
+
};
|
|
27742
|
+
}
|
|
27743
|
+
function combinedUtilization(p, readUtilization, writeUtilization) {
|
|
27744
|
+
return p.writeScaling === "shards" ? Math.max(readUtilization, writeUtilization) : readUtilization + writeUtilization;
|
|
27745
|
+
}
|
|
27746
|
+
|
|
27387
27747
|
// ../frontend/src/sim/flow.ts
|
|
27388
27748
|
function findUseCase(diagram, name) {
|
|
27389
27749
|
const lower = name.toLowerCase();
|
|
@@ -27448,9 +27808,9 @@ function requestOrder(useCase, scenario) {
|
|
|
27448
27808
|
}
|
|
27449
27809
|
return { requests, beforeResponse: beforeResponse < 0 ? requests.length : beforeResponse };
|
|
27450
27810
|
}
|
|
27451
|
-
function writesBeforeResponse(useCase, scenario, accept) {
|
|
27811
|
+
function writesBeforeResponse(useCase, scenario, accept, isWrite = (step) => stepAccess(step) === "write") {
|
|
27452
27812
|
const { requests, beforeResponse } = requestOrder(useCase, scenario);
|
|
27453
|
-
return requests.slice(0, beforeResponse).some((m) => !m.async && !m.failed && accept(m.to));
|
|
27813
|
+
return requests.slice(0, beforeResponse).some((m) => !m.async && !m.failed && accept(m.to) && isWrite(m.step));
|
|
27454
27814
|
}
|
|
27455
27815
|
function callsOk(scenario, nodeId) {
|
|
27456
27816
|
return scenario.steps.some((s) => s.toServiceId === nodeId && !s.failed);
|
|
@@ -27459,23 +27819,34 @@ function needs(useCase, nodeId) {
|
|
|
27459
27819
|
return useCase.scenarios.some((s) => s.outcome === "success" && callsOk(s, nodeId));
|
|
27460
27820
|
}
|
|
27461
27821
|
function fallbacksFor(useCase, nodeId) {
|
|
27462
|
-
return useCase.scenarios.filter(
|
|
27463
|
-
(s
|
|
27464
|
-
|
|
27822
|
+
return useCase.scenarios.filter((s) => {
|
|
27823
|
+
if (s.outcome !== "success") return false;
|
|
27824
|
+
const { requests, beforeResponse } = requestOrder(useCase, s);
|
|
27825
|
+
const early = requests.slice(0, beforeResponse).filter((m) => m.to === nodeId);
|
|
27826
|
+
const window = early.some((m) => m.failed) ? early : requests.filter((m) => m.to === nodeId);
|
|
27827
|
+
return window.some((m) => m.failed) && !window.some((m) => !m.failed && !m.async);
|
|
27828
|
+
});
|
|
27465
27829
|
}
|
|
27466
27830
|
function selectorText(selector) {
|
|
27467
27831
|
if ("node" in selector) return selector.node;
|
|
27468
27832
|
if ("tech" in selector) return `[${selector.tech}]`;
|
|
27833
|
+
if ("anyOf" in selector) return selector.anyOf.map(selectorText).join(" or ");
|
|
27834
|
+
if ("consistency" in selector) return `any ${selector.consistency} store`;
|
|
27469
27835
|
return `any ${selector.kind}`;
|
|
27470
27836
|
}
|
|
27471
27837
|
function selectNodes(diagram, selector) {
|
|
27472
|
-
return diagram.nodes.filter((n) => n.kind === "component" && matches(n, selector));
|
|
27838
|
+
return diagram.nodes.filter((n) => n.kind === "component" && matches(n, selector, diagram));
|
|
27473
27839
|
}
|
|
27474
|
-
function matches(node, selector) {
|
|
27840
|
+
function matches(node, selector, diagram) {
|
|
27475
27841
|
if (node.kind !== "component") return false;
|
|
27476
27842
|
if ("node" in selector) return node.id === selector.node;
|
|
27477
27843
|
if ("tech" in selector) return node.techStack.toLowerCase() === selector.tech.toLowerCase();
|
|
27478
|
-
|
|
27844
|
+
if ("anyOf" in selector) return selector.anyOf.some((s) => matches(node, s, diagram));
|
|
27845
|
+
if ("consistency" in selector) {
|
|
27846
|
+
const override = diagram?.capacity?.find((o) => o.node === node.id);
|
|
27847
|
+
return profileOf(node, override).consistency === selector.consistency;
|
|
27848
|
+
}
|
|
27849
|
+
return kindMatches(kindOf(node), selector.kind);
|
|
27479
27850
|
}
|
|
27480
27851
|
function techOf(node) {
|
|
27481
27852
|
return node && !node.implicit ? node.techStack : void 0;
|
|
@@ -27509,51 +27880,13 @@ function formatUsd(usd) {
|
|
|
27509
27880
|
return `$${Math.round(usd).toLocaleString("en-US")}/month`;
|
|
27510
27881
|
}
|
|
27511
27882
|
|
|
27512
|
-
// ../frontend/src/sim/profiles.ts
|
|
27513
|
-
var NEUTRAL = { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false };
|
|
27514
|
-
var KIND_PROFILES = {
|
|
27515
|
-
client: NEUTRAL,
|
|
27516
|
-
edge: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
|
|
27517
|
-
service: { rps: 2e3, latencyMs: 10, availability: 0.995, costUsd: 100, durable: false },
|
|
27518
|
-
function: { rps: 1e4, latencyMs: 25, availability: 0.9995, costUsd: 200, durable: false },
|
|
27519
|
-
cache: { rps: 1e5, latencyMs: 1, availability: 0.999, costUsd: 150, durable: false },
|
|
27520
|
-
database: { rps: 5e3, latencyMs: 5, availability: 0.9995, costUsd: 400, durable: true },
|
|
27521
|
-
search: { rps: 3e3, latencyMs: 15, availability: 0.999, costUsd: 400, durable: true },
|
|
27522
|
-
analytics: { rps: 200, latencyMs: 500, availability: 0.999, costUsd: 300, durable: true },
|
|
27523
|
-
queue: { rps: 5e4, latencyMs: 5, availability: 0.9999, costUsd: 200, durable: true },
|
|
27524
|
-
storage: { rps: 5e3, latencyMs: 30, availability: 0.9999, costUsd: 50, durable: true },
|
|
27525
|
-
external: { rps: 1e3, latencyMs: 200, availability: 0.999, costUsd: 0, durable: false },
|
|
27526
|
-
other: NEUTRAL
|
|
27527
|
-
};
|
|
27528
|
-
var NOSQL = { rps: 2e4, latencyMs: 5, availability: 0.9999, costUsd: 500, durable: true };
|
|
27529
|
-
var TECH_PROFILES = {
|
|
27530
|
-
DynamoDB: NOSQL,
|
|
27531
|
-
"AWS DynamoDB": NOSQL,
|
|
27532
|
-
Cassandra: NOSQL,
|
|
27533
|
-
MongoDB: NOSQL,
|
|
27534
|
-
CouchDB: NOSQL,
|
|
27535
|
-
"Azure Cosmos DB": NOSQL,
|
|
27536
|
-
"GCP Bigtable": NOSQL,
|
|
27537
|
-
"GCP Firestore": NOSQL,
|
|
27538
|
-
"GCP Spanner": NOSQL
|
|
27539
|
-
};
|
|
27540
|
-
function profileOf(node, override) {
|
|
27541
|
-
const kind = kindOf(node);
|
|
27542
|
-
const base = TECH_PROFILES[node.techStack] ?? KIND_PROFILES[kind];
|
|
27543
|
-
return {
|
|
27544
|
-
kind,
|
|
27545
|
-
rps: override?.rps ?? base.rps,
|
|
27546
|
-
latencyMs: override?.latencyMs ?? base.latencyMs,
|
|
27547
|
-
availability: override?.availability !== void 0 ? override.availability / 100 : base.availability,
|
|
27548
|
-
costUsd: override?.costUsd ?? base.costUsd,
|
|
27549
|
-
durable: override?.durable ?? base.durable
|
|
27550
|
-
};
|
|
27551
|
-
}
|
|
27552
|
-
|
|
27553
27883
|
// ../frontend/src/sim/analyze.ts
|
|
27554
27884
|
var DEFAULT_TIMEOUT_MS = 1e3;
|
|
27555
27885
|
var HOT = 0.7;
|
|
27556
27886
|
var QUEUE_CAP = 0.95;
|
|
27887
|
+
var SECONDS_PER_MONTH = 2592e3;
|
|
27888
|
+
var BYTES_PER_MB = 1e6;
|
|
27889
|
+
var BYTES_PER_GB = 1e9;
|
|
27557
27890
|
var PERCENTILE_FACTORS = { 50: 1, 90: 1.6, 95: 2, 99: 3, 99.9: 5 };
|
|
27558
27891
|
var PERCENTILE_KEYS = { 50: "p50", 90: "p90", 95: "p95", 99: "p99", 99.9: "p999" };
|
|
27559
27892
|
var PERCENTILES2 = Object.keys(PERCENTILE_KEYS).map(Number);
|
|
@@ -27589,20 +27922,73 @@ function resolveTraffic(diagram, warnings = []) {
|
|
|
27589
27922
|
}
|
|
27590
27923
|
return result;
|
|
27591
27924
|
}
|
|
27592
|
-
|
|
27593
|
-
|
|
27925
|
+
var multiplierOf = (step) => step.multiplier !== void 0 && step.multiplier > 0 ? step.multiplier : 1;
|
|
27926
|
+
function forEachStepRate(diagram, traffic, visit) {
|
|
27594
27927
|
for (const u of diagram.useCases) {
|
|
27595
27928
|
const { rps, shares } = traffic.get(u.id) ?? { rps: 0, shares: [] };
|
|
27596
27929
|
u.scenarios.forEach((s, i) => {
|
|
27597
27930
|
const rate = rps * (shares[i] ?? 0);
|
|
27598
|
-
if (rate > 0) for (const step of s.steps)
|
|
27931
|
+
if (rate > 0) for (const step of s.steps) visit(step, rate);
|
|
27599
27932
|
});
|
|
27600
27933
|
}
|
|
27934
|
+
}
|
|
27935
|
+
function computeLoad(diagram, traffic) {
|
|
27936
|
+
const load = /* @__PURE__ */ new Map();
|
|
27937
|
+
const access = accessIn(diagram);
|
|
27938
|
+
forEachStepRate(diagram, traffic, (step, rate) => {
|
|
27939
|
+
const entry = load.get(step.toServiceId) ?? { read: 0, write: 0 };
|
|
27940
|
+
entry[access(step)] += rate * multiplierOf(step);
|
|
27941
|
+
load.set(step.toServiceId, entry);
|
|
27942
|
+
});
|
|
27601
27943
|
return load;
|
|
27602
27944
|
}
|
|
27945
|
+
var egressSource = (step, access) => access === "read" ? step.toServiceId : step.fromServiceId;
|
|
27946
|
+
function computeEgressGb(diagram, traffic) {
|
|
27947
|
+
const gb = /* @__PURE__ */ new Map();
|
|
27948
|
+
const access = accessIn(diagram);
|
|
27949
|
+
forEachStepRate(diagram, traffic, (step, rate) => {
|
|
27950
|
+
if (!step.sizeBytes || step.sizeBytes <= 0) return;
|
|
27951
|
+
const source2 = egressSource(step, access(step));
|
|
27952
|
+
gb.set(source2, (gb.get(source2) ?? 0) + rate * multiplierOf(step) * step.sizeBytes * SECONDS_PER_MONTH / BYTES_PER_GB);
|
|
27953
|
+
});
|
|
27954
|
+
return gb;
|
|
27955
|
+
}
|
|
27956
|
+
function transferMs(step, bandwidthOf) {
|
|
27957
|
+
if (!step.sizeBytes || step.sizeBytes <= 0) return 0;
|
|
27958
|
+
const mbps = Math.min(bandwidthOf(step.fromServiceId), bandwidthOf(step.toServiceId));
|
|
27959
|
+
return mbps > 0 && Number.isFinite(mbps) ? step.sizeBytes / BYTES_PER_MB / mbps * 1e3 : 0;
|
|
27960
|
+
}
|
|
27603
27961
|
var utilizationOf = (load, capacity) => Number.isFinite(capacity) && capacity > 0 ? load / capacity : 0;
|
|
27604
27962
|
var hopLatency = (baseMs, utilization) => baseMs / (1 - Math.min(utilization, QUEUE_CAP));
|
|
27605
27963
|
var replicatedAvailability = (a, replicas) => 1 - (1 - a) ** replicas;
|
|
27964
|
+
function usageOf(p, replicas, readLoadRps, writeLoadRps) {
|
|
27965
|
+
const { readRps: readCapacityRps, writeRps: writeCapacityRps } = capacityOf(p, replicas);
|
|
27966
|
+
const readUtilization = utilizationOf(readLoadRps, readCapacityRps);
|
|
27967
|
+
const writeUtilization = utilizationOf(writeLoadRps, writeCapacityRps);
|
|
27968
|
+
const utilization = combinedUtilization(p, readUtilization, writeUtilization);
|
|
27969
|
+
const loadRps = readLoadRps + writeLoadRps;
|
|
27970
|
+
return {
|
|
27971
|
+
loadRps,
|
|
27972
|
+
capacityRps: readCapacityRps === writeCapacityRps || utilization === 0 ? readCapacityRps : loadRps / utilization,
|
|
27973
|
+
utilization,
|
|
27974
|
+
saturated: utilization >= 1,
|
|
27975
|
+
readLoadRps,
|
|
27976
|
+
writeLoadRps,
|
|
27977
|
+
readCapacityRps,
|
|
27978
|
+
writeCapacityRps,
|
|
27979
|
+
readUtilization,
|
|
27980
|
+
writeUtilization,
|
|
27981
|
+
writeScaling: p.writeScaling
|
|
27982
|
+
};
|
|
27983
|
+
}
|
|
27984
|
+
function usageText(n) {
|
|
27985
|
+
const part = (load, capacity, rho) => `${formatRps(load)} of ${formatRps(capacity)}, ${formatPercent(rho)}`;
|
|
27986
|
+
if (n.writeScaling === "shards" && n.writeLoadRps > 0) {
|
|
27987
|
+
return `reads ${part(n.readLoadRps, n.readCapacityRps, n.readUtilization)}; writes ${part(n.writeLoadRps, n.writeCapacityRps, n.writeUtilization)}`;
|
|
27988
|
+
}
|
|
27989
|
+
return part(n.loadRps, n.capacityRps, n.utilization);
|
|
27990
|
+
}
|
|
27991
|
+
var writeBound = (n) => n.writeScaling === "shards" && n.writeUtilization >= n.readUtilization && n.writeLoadRps > 0;
|
|
27606
27992
|
function useCasePercentile(scenarios, q) {
|
|
27607
27993
|
const tail = 1 - q / 100 - 1e-9;
|
|
27608
27994
|
const heavy = scenarios.filter((s) => s.share >= tail && s.share > 0);
|
|
@@ -27621,49 +28007,57 @@ function mainScenarioIndex(shares) {
|
|
|
27621
28007
|
});
|
|
27622
28008
|
return best;
|
|
27623
28009
|
}
|
|
28010
|
+
function profilesOf(diagram, warnings = []) {
|
|
28011
|
+
const nodes = components(diagram);
|
|
28012
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
28013
|
+
const overrides = new Map((diagram.capacity ?? []).map((o) => [o.node, o]));
|
|
28014
|
+
for (const o of diagram.capacity ?? []) if (!ids.has(o.node)) warnings.push(`Capacity for unknown node '${o.node}' is ignored`);
|
|
28015
|
+
return new Map(nodes.map((n) => [n.id, profileOf(n, overrides.get(n.id))]));
|
|
28016
|
+
}
|
|
27624
28017
|
function analyze(diagram, options = {}) {
|
|
27625
28018
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
27626
28019
|
const warnings = [];
|
|
27627
28020
|
const nodes = components(diagram);
|
|
27628
|
-
const
|
|
27629
|
-
const overrides = new Map((diagram.capacity ?? []).map((o) => [o.node, o]));
|
|
27630
|
-
for (const o of diagram.capacity ?? []) if (!byId.has(o.node)) warnings.push(`Capacity for unknown node '${o.node}' is ignored`);
|
|
27631
|
-
const profiles = new Map(nodes.map((n) => [n.id, profileOf(n, overrides.get(n.id))]));
|
|
28021
|
+
const profiles = profilesOf(diagram, warnings);
|
|
27632
28022
|
const traffic = resolveTraffic(diagram, warnings);
|
|
27633
28023
|
const load = computeLoad(diagram, traffic);
|
|
28024
|
+
const egress = computeEgressGb(diagram, traffic);
|
|
27634
28025
|
const nodeResults = nodes.map((n) => {
|
|
27635
28026
|
const p = profiles.get(n.id);
|
|
27636
28027
|
const replicas = replicasOf(n);
|
|
27637
|
-
const
|
|
27638
|
-
const
|
|
27639
|
-
const
|
|
28028
|
+
const { read, write } = load.get(n.id) ?? { read: 0, write: 0 };
|
|
28029
|
+
const usage = usageOf(p, replicas, read, write);
|
|
28030
|
+
const egressGbPerMonth = egress.get(n.id) ?? 0;
|
|
28031
|
+
const egressUsd = egressGbPerMonth * p.egressUsdPerGb;
|
|
27640
28032
|
return {
|
|
27641
28033
|
id: n.id,
|
|
27642
28034
|
kind: p.kind,
|
|
27643
28035
|
replicas,
|
|
27644
|
-
|
|
27645
|
-
|
|
27646
|
-
|
|
27647
|
-
|
|
27648
|
-
latencyMs: hopLatency(p.latencyMs, utilization),
|
|
28036
|
+
shards: p.shards,
|
|
28037
|
+
...usage,
|
|
28038
|
+
...p.consistency ? { consistency: p.consistency } : {},
|
|
28039
|
+
latencyMs: hopLatency(p.latencyMs, usage.utilization),
|
|
27649
28040
|
availability: replicatedAvailability(p.availability, replicas),
|
|
27650
|
-
costUsd: replicas * p.costUsd,
|
|
28041
|
+
costUsd: replicas * p.shards * p.costUsd + egressUsd,
|
|
28042
|
+
egressGbPerMonth,
|
|
28043
|
+
egressUsd,
|
|
27651
28044
|
durable: p.durable
|
|
27652
28045
|
};
|
|
27653
28046
|
});
|
|
27654
28047
|
const nodeById = new Map(nodeResults.map((n) => [n.id, n]));
|
|
27655
28048
|
const hopMs = (id) => nodeById.get(id)?.latencyMs ?? 0;
|
|
27656
28049
|
const availabilityOf = (id) => nodeById.get(id)?.availability ?? 1;
|
|
28050
|
+
const bandwidthOf = (id) => profiles.get(id)?.bandwidthMBps ?? Infinity;
|
|
27657
28051
|
for (const n of nodeResults) {
|
|
27658
|
-
const usage =
|
|
27659
|
-
if (n.saturated) warnings.push(`'${n.id}' is saturated: ${usage}. Add replicas or take load off it.`);
|
|
28052
|
+
const usage = usageText(n);
|
|
28053
|
+
if (n.saturated) warnings.push(`'${n.id}' is saturated: ${usage}. ${writeBound(n) ? "Add shards for writes" : "Add replicas"} or take load off it.`);
|
|
27660
28054
|
else if (n.utilization > HOT) warnings.push(`'${n.id}' runs hot: ${usage}.`);
|
|
27661
28055
|
}
|
|
27662
28056
|
const useCases = diagram.useCases.map((u) => {
|
|
27663
28057
|
const { rps, shares } = traffic.get(u.id);
|
|
27664
28058
|
const scenarios = u.scenarios.map((s, i) => {
|
|
27665
28059
|
const meanMs = criticalPath(u, s).reduce(
|
|
27666
|
-
(sum, part) => sum + Math.max(...part.map((hop) => hop.failed ? timeoutMs : hopMs(hop.target))),
|
|
28060
|
+
(sum, part) => sum + Math.max(...part.map((hop) => hop.failed ? timeoutMs : hopMs(hop.target) + transferMs(hop.step, bandwidthOf))),
|
|
27667
28061
|
0
|
|
27668
28062
|
);
|
|
27669
28063
|
return { id: s.id, name: s.name, share: shares[i] ?? 0, meanMs, percentiles: percentilesOf(meanMs) };
|
|
@@ -27678,6 +28072,7 @@ function analyze(diagram, options = {}) {
|
|
|
27678
28072
|
nodes: nodeResults,
|
|
27679
28073
|
useCases,
|
|
27680
28074
|
totalCostUsd: nodeResults.reduce((sum, n) => sum + n.costUsd, 0),
|
|
28075
|
+
totalEgressUsd: nodeResults.reduce((sum, n) => sum + n.egressUsd, 0),
|
|
27681
28076
|
singlePointsOfFailure: singlePointsOfFailure(diagram),
|
|
27682
28077
|
warnings
|
|
27683
28078
|
};
|
|
@@ -27702,7 +28097,7 @@ function useCaseAvailability(useCase, shares, availabilityOf) {
|
|
|
27702
28097
|
}
|
|
27703
28098
|
return result;
|
|
27704
28099
|
}
|
|
27705
|
-
var DEPLOYED = (kind) => kind !== "client" && kind !== "other" && kind !== "external";
|
|
28100
|
+
var DEPLOYED = (kind) => kind !== "client" && kind !== "other" && kind !== "external" && kind !== "dns";
|
|
27706
28101
|
function singlePointsOfFailure(diagram) {
|
|
27707
28102
|
return components(diagram).filter((n) => replicasOf(n) === 1 && DEPLOYED(profileOf(n).kind)).filter((n) => diagram.useCases.some((u) => needs(u, n.id) && fallbacksFor(u, n.id).length === 0)).map((n) => n.id);
|
|
27708
28103
|
}
|
|
@@ -27711,20 +28106,20 @@ function singlePointsOfFailure(diagram) {
|
|
|
27711
28106
|
var pass = (message) => ({ passed: true, message });
|
|
27712
28107
|
var fail = (message, hint) => ({ passed: false, message, hint });
|
|
27713
28108
|
function runTests(diagram, analysis = analyze(diagram)) {
|
|
28109
|
+
const access = accessIn(diagram);
|
|
27714
28110
|
const run = {
|
|
27715
28111
|
diagram,
|
|
27716
28112
|
analysis,
|
|
27717
28113
|
nodes: new Map(analysis.nodes.map((n) => [n.id, n])),
|
|
27718
28114
|
useCases: new Map(analysis.useCases.map((u) => [u.id, u])),
|
|
27719
|
-
shares: new Map([...resolveTraffic(diagram)].map(([id, t]) => [id, t.shares]))
|
|
28115
|
+
shares: new Map([...resolveTraffic(diagram)].map(([id, t]) => [id, t.shares])),
|
|
28116
|
+
profiles: profilesOf(diagram),
|
|
28117
|
+
isWrite: (step) => access(step) === "write"
|
|
27720
28118
|
};
|
|
27721
|
-
const results = (diagram.requirements ?? []).map((r, i) =>
|
|
27722
|
-
|
|
27723
|
-
name: requirementName(r),
|
|
27724
|
-
|
|
27725
|
-
...requirement(run, r),
|
|
27726
|
-
loc: r.loc
|
|
27727
|
-
}));
|
|
28119
|
+
const results = (diagram.requirements ?? []).map((r, i) => {
|
|
28120
|
+
const { passed, message, hint } = requirement(run, r);
|
|
28121
|
+
return { id: `req:${i + 1}`, name: requirementName(r), category: CATEGORY[r.kind], passed, message, hint, loc: r.loc };
|
|
28122
|
+
});
|
|
27728
28123
|
const names = /* @__PURE__ */ new Map();
|
|
27729
28124
|
for (const test of diagram.tests ?? []) {
|
|
27730
28125
|
const n = (names.get(test.name) ?? 0) + 1;
|
|
@@ -27741,10 +28136,16 @@ var CATEGORY = {
|
|
|
27741
28136
|
cost: "cost"
|
|
27742
28137
|
};
|
|
27743
28138
|
var percentileName = (q) => PERCENTILE_KEYS[q];
|
|
28139
|
+
var lineOf = (loc) => `${loc.file ? `${loc.file}:` : "line "}${loc.line}`;
|
|
28140
|
+
function stepText(step) {
|
|
28141
|
+
const arrow = step.failed ? "-x" : step.executionType === "SYNC_REQUEST_RESPONSE" ? "->" : "->>";
|
|
28142
|
+
const label = requestLabel(step);
|
|
28143
|
+
return `${step.fromServiceId} ${arrow} ${step.toServiceId}${label ? ` : ${label}` : ""} at ${lineOf(step.loc)}`;
|
|
28144
|
+
}
|
|
27744
28145
|
function requirementName(r) {
|
|
27745
28146
|
switch (r.kind) {
|
|
27746
28147
|
case "latency":
|
|
27747
|
-
return `${percentileName(r.percentile)} of ${r.useCase ?? "every use case"} < ${formatMs(r.maxMs)}`;
|
|
28148
|
+
return `${percentileName(r.percentile)} of ${r.useCase ?? "every use case"}${r.scenario !== void 0 ? ` scenario ${r.scenario}` : ""} < ${formatMs(r.maxMs)}`;
|
|
27748
28149
|
case "availability":
|
|
27749
28150
|
return `availability of ${r.useCase ?? "every use case"} \u2265 ${r.minPercent}%`;
|
|
27750
28151
|
case "durable":
|
|
@@ -27758,6 +28159,7 @@ function requirementName(r) {
|
|
|
27758
28159
|
function requirement(run, r) {
|
|
27759
28160
|
switch (r.kind) {
|
|
27760
28161
|
case "latency":
|
|
28162
|
+
if (r.useCase !== void 0 && r.scenario !== void 0) return scenarioLatency(run, r.useCase, r.scenario, r.percentile, r.maxMs);
|
|
27761
28163
|
return overUseCases(run, r.useCase, (u) => latency(run, u, r.percentile, r.maxMs), `(limit ${formatMs(r.maxMs)})`);
|
|
27762
28164
|
case "availability":
|
|
27763
28165
|
return overUseCases(run, r.useCase, (u) => availability(run, u, r.minPercent), `(limit ${r.minPercent}%)`, true);
|
|
@@ -27786,43 +28188,65 @@ function overUseCases(run, name, check, limit, anyWithoutTraffic = false) {
|
|
|
27786
28188
|
}
|
|
27787
28189
|
function unknownUseCase(run, name) {
|
|
27788
28190
|
const known = run.diagram.useCases.map((u) => `"${u.name}"`);
|
|
27789
|
-
return fail(`No use case named "${name}"`, known.length ? `Use one of ${known.join(", ")}` : 'Add a usecase "\u2026" { \u2026 } block');
|
|
28191
|
+
return { ...fail(`No use case named "${name}"`, known.length ? `Use one of ${known.join(", ")}` : 'Add a usecase "\u2026" { \u2026 } block'), missing: `use case ${name}` };
|
|
28192
|
+
}
|
|
28193
|
+
function saturatedOn(run, ids, value, maxMs) {
|
|
28194
|
+
const n = [...new Set(ids)].map((id) => run.nodes.get(id)).find((x) => !!x?.saturated);
|
|
28195
|
+
if (!n) return void 0;
|
|
28196
|
+
const v = value(n);
|
|
28197
|
+
return { ...fail(`${v} (limit ${formatMs(maxMs)})`, saturationHint(run, n)), value: v };
|
|
27790
28198
|
}
|
|
27791
28199
|
function latency(run, u, q, maxMs) {
|
|
27792
28200
|
const result = run.useCases.get(u.id);
|
|
27793
28201
|
const key = percentileName(q);
|
|
27794
28202
|
const shares = run.shares.get(u.id) ?? [];
|
|
27795
|
-
const touched =
|
|
27796
|
-
const saturated =
|
|
27797
|
-
if (saturated
|
|
27798
|
-
const n = saturated[0];
|
|
27799
|
-
const usage = `${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)}, ${formatPercent(n.utilization)}`;
|
|
27800
|
-
const value2 = `${key} of ${u.name}: ${n.id} is saturated (${usage})`;
|
|
27801
|
-
return { ...fail(`${value2} (limit ${formatMs(maxMs)})`, saturationHint(run, n)), value: value2 };
|
|
27802
|
-
}
|
|
28203
|
+
const touched = u.scenarios.flatMap((s, i) => (shares[i] ?? 0) > 0 ? s.steps.map((step) => step.toServiceId) : []);
|
|
28204
|
+
const saturated = saturatedOn(run, touched, (n) => `${key} of ${u.name}: ${n.id} is saturated (${usageText(n)})`, maxMs);
|
|
28205
|
+
if (saturated) return saturated;
|
|
27803
28206
|
const ms = result.percentiles[key];
|
|
27804
28207
|
const value = `${key} of ${u.name} is ${formatMs(ms)}${result.rps > 0 ? "" : " with no traffic"}`;
|
|
27805
28208
|
const message = `${value} (limit ${formatMs(maxMs)})`;
|
|
27806
|
-
|
|
28209
|
+
if (ms < maxMs) return { ...pass(message), value };
|
|
28210
|
+
const index = Math.max(0, result.scenarios.findIndex((s) => s.percentiles[key] === ms));
|
|
28211
|
+
const lead = u.scenarios.length > 1 ? `The "${u.scenarios[index].name}" path (${formatPercent(result.scenarios[index].share)} of traffic) sets ${key}. ` : "";
|
|
28212
|
+
return { ...fail(message, lead + latencyHint(run, u, u.scenarios[index])), value };
|
|
28213
|
+
}
|
|
28214
|
+
function scenarioLatency(run, useCase, scenario, q, maxMs) {
|
|
28215
|
+
const sc = scope(run, useCase, scenario);
|
|
28216
|
+
if (isCheck(sc)) return sc;
|
|
28217
|
+
const s = sc.scenarios[0];
|
|
28218
|
+
const key = percentileName(q);
|
|
28219
|
+
const what = `${key} of "${sc.u.name}" scenario "${s.name}"`;
|
|
28220
|
+
const saturated = saturatedOn(run, s.steps.map((step) => step.toServiceId), (n) => `${what}: ${n.id} is saturated (${usageText(n)})`, maxMs);
|
|
28221
|
+
if (saturated) return saturated;
|
|
28222
|
+
const result = run.useCases.get(sc.u.id)?.scenarios[sc.u.scenarios.indexOf(s)];
|
|
28223
|
+
const ms = result?.percentiles[key] ?? 0;
|
|
28224
|
+
const message = `${what} is ${formatMs(ms)} (limit ${formatMs(maxMs)})`;
|
|
28225
|
+
return ms < maxMs ? pass(message) : fail(message, latencyHint(run, sc.u, s));
|
|
27807
28226
|
}
|
|
27808
28227
|
function saturationHint(run, n) {
|
|
27809
|
-
const
|
|
27810
|
-
|
|
27811
|
-
|
|
28228
|
+
const label = nodeLabel(run.diagram, n.id);
|
|
28229
|
+
if (writeBound(n)) {
|
|
28230
|
+
const perShard = n.writeCapacityRps / n.shards;
|
|
28231
|
+
const shards = Math.ceil(n.writeLoadRps / (perShard * HOT));
|
|
28232
|
+
return `Writes to ${label} need ${formatRps(n.writeLoadRps)} and each primary takes ${formatRps(perShard)}; read replicas do not add write capacity. Add shards (capacity { ${n.id} shards ${shards} } keeps writes under ${formatPercent(HOT)}), move the data to a partitioned store (DynamoDB, Cassandra), or batch writes through a queue`;
|
|
28233
|
+
}
|
|
28234
|
+
const rho = n.writeScaling === "shards" ? n.readUtilization : n.utilization;
|
|
28235
|
+
const replicas = Math.ceil(rho * n.replicas / HOT);
|
|
28236
|
+
return `Add replicas to ${label}: x${replicas} keeps it under ${formatPercent(HOT)}, or take load off it (a cache in front, or async work)`;
|
|
27812
28237
|
}
|
|
27813
|
-
function latencyHint(run, u,
|
|
27814
|
-
const index = result.scenarios.findIndex((s) => s.percentiles[key] === ms);
|
|
27815
|
-
const scenario = u.scenarios[Math.max(0, index)];
|
|
27816
|
-
const lead = u.scenarios.length > 1 ? `The "${scenario.name}" path (${formatPercent(result.scenarios[Math.max(0, index)].share)} of traffic) sets ${key}. ` : "";
|
|
28238
|
+
function latencyHint(run, u, scenario) {
|
|
27817
28239
|
const hops = criticalPath(u, scenario).flat();
|
|
27818
28240
|
const cost2 = (h) => h.failed ? DEFAULT_TIMEOUT_MS : run.nodes.get(h.target)?.latencyMs ?? 0;
|
|
27819
28241
|
const slowest = hops.reduce((best, h) => !best || cost2(h) > cost2(best) ? h : best, void 0);
|
|
27820
|
-
if (!slowest) return
|
|
28242
|
+
if (!slowest) return "Shorten the synchronous path of the request.";
|
|
27821
28243
|
const label = nodeLabel(run.diagram, slowest.target);
|
|
27822
|
-
if (slowest.failed) return
|
|
28244
|
+
if (slowest.failed) return `It waits ${formatMs(DEFAULT_TIMEOUT_MS)} for the failed call to ${label}; give that path a smaller share of traffic or avoid the call.`;
|
|
28245
|
+
const sized = hops.find((h) => (h.step.sizeBytes ?? 0) > 0);
|
|
27823
28246
|
const node = run.nodes.get(slowest.target);
|
|
27824
|
-
if (node && node.utilization > HOT) return `${
|
|
27825
|
-
|
|
28247
|
+
if (node && node.utilization > HOT) return `${label} runs at ${formatPercent(node.utilization)} and queues requests; add replicas to it.`;
|
|
28248
|
+
const payload = sized ? ` Payloads add transfer time too (${stepText(sized.step)}): send fewer bytes or let the client fetch them from a CDN or storage directly.` : "";
|
|
28249
|
+
return `Its slowest hop is ${label} at ${formatMs(cost2(slowest))}: put a cache in front of it, run independent calls in par, or move work off the request path with ->>.${payload}`;
|
|
27826
28250
|
}
|
|
27827
28251
|
function availability(run, u, minPercent) {
|
|
27828
28252
|
const result = run.useCases.get(u.id);
|
|
@@ -27835,37 +28259,53 @@ function availability(run, u, minPercent) {
|
|
|
27835
28259
|
const hint = weakest ? `The weakest link is ${nodeLabel(run.diagram, weakest.id)} at ${formatAvailability(weakest.availability)}: add a replica (x${weakest.replicas + 1}) or a fallback scenario that calls it with -x and still completes` : "Add replicas to the nodes on the main path, or fallback scenarios that complete without them";
|
|
27836
28260
|
return { ...fail(message, hint), value };
|
|
27837
28261
|
}
|
|
27838
|
-
function writeGap(u, s, accept) {
|
|
28262
|
+
function writeGap(run, u, s, accept) {
|
|
27839
28263
|
const { requests, beforeResponse } = requestOrder(u, s);
|
|
27840
|
-
const
|
|
27841
|
-
|
|
27842
|
-
|
|
27843
|
-
return "
|
|
28264
|
+
const hits = requests.map((m, i) => ({ m, i })).filter(({ m }) => !m.failed && accept(m.to));
|
|
28265
|
+
const writes = hits.filter(({ m }) => run.isWrite(m.step));
|
|
28266
|
+
const early = writes.find(({ m, i }) => m.async && i < beforeResponse);
|
|
28267
|
+
if (early) return { kind: "async", step: early.m.step };
|
|
28268
|
+
const late = writes.find(({ i }) => i >= beforeResponse);
|
|
28269
|
+
if (late) return { kind: "after", step: late.m.step };
|
|
28270
|
+
const read = hits.find(({ m, i }) => i < beforeResponse && !m.async);
|
|
28271
|
+
if (read) return { kind: "read", step: read.m.step };
|
|
28272
|
+
return { kind: "none" };
|
|
27844
28273
|
}
|
|
27845
|
-
|
|
27846
|
-
async: "writes only asynchronously (->>)",
|
|
27847
|
-
after: "writes only after responding",
|
|
27848
|
-
none: "never writes"
|
|
27849
|
-
};
|
|
27850
|
-
function writesCheck(u, scenarios, accept, what) {
|
|
28274
|
+
function writesCheck(run, u, scenarios, accept, what) {
|
|
27851
28275
|
const success = scenarios.filter((s) => s.outcome === "success");
|
|
27852
28276
|
if (success.length === 0) return fail(`"${u.name}" has no success scenario to check`, "Add a scenario whose entry request is answered with a 2xx");
|
|
27853
|
-
const gaps = success.
|
|
28277
|
+
const gaps = success.filter((s) => !writesBeforeResponse(u, s, accept, run.isWrite));
|
|
27854
28278
|
if (gaps.length === 0) {
|
|
27855
28279
|
const label = success.length === 1 ? `"${u.name}"` : `Every success scenario of "${u.name}"`;
|
|
27856
|
-
return pass(`${label} writes ${what} before responding`);
|
|
28280
|
+
return pass(`${label} writes to ${what} before responding`);
|
|
27857
28281
|
}
|
|
27858
|
-
const details = gaps.map((
|
|
28282
|
+
const details = gaps.map((s) => ({ s, gap: writeGap(run, u, s, accept) }));
|
|
27859
28283
|
const where = (s) => u.scenarios.length > 1 ? `"${s.name}"` : `"${u.name}"`;
|
|
27860
|
-
const
|
|
27861
|
-
|
|
27862
|
-
|
|
27863
|
-
|
|
28284
|
+
const text = (s, gap) => {
|
|
28285
|
+
switch (gap.kind) {
|
|
28286
|
+
case "async":
|
|
28287
|
+
return `${where(s)} writes to ${what} only asynchronously: ${stepText(gap.step)}`;
|
|
28288
|
+
case "after":
|
|
28289
|
+
return `${where(s)} writes to ${what} only after responding: ${stepText(gap.step)}`;
|
|
28290
|
+
case "read":
|
|
28291
|
+
return `${where(s)} only reads from ${what} before responding: ${stepText(gap.step)} is a read`;
|
|
28292
|
+
case "none":
|
|
28293
|
+
return `${where(s)} never writes to ${what} before responding`;
|
|
28294
|
+
}
|
|
28295
|
+
};
|
|
28296
|
+
const message = details.map(({ s, gap }) => text(s, gap)).join("; ");
|
|
28297
|
+
const hints = {
|
|
28298
|
+
async: "Make the write a synchronous request (->) so it is acknowledged before the response",
|
|
28299
|
+
after: "Move the write before the step that answers the entry request",
|
|
28300
|
+
read: "Only writes count: start the label with a write verb (INSERT, UPDATE, PUT, SET, \u2026) or use POST, PUT, PATCH or DELETE if the step stores data",
|
|
28301
|
+
none: `Add a synchronous write (->) to ${what} before the entry request is answered`
|
|
28302
|
+
};
|
|
28303
|
+
return fail(message, hints[details[0].gap.kind]);
|
|
27864
28304
|
}
|
|
27865
28305
|
function durable(run, name) {
|
|
27866
28306
|
const u = findUseCase(run.diagram, name);
|
|
27867
28307
|
if (!u) return unknownUseCase(run, name);
|
|
27868
|
-
return writesCheck(u, u.scenarios, (id) => !!run.nodes.get(id)?.durable, "
|
|
28308
|
+
return writesCheck(run, u, u.scenarios, (id) => !!run.nodes.get(id)?.durable, "a durable store");
|
|
27869
28309
|
}
|
|
27870
28310
|
function survive(run, target) {
|
|
27871
28311
|
const all = components(run.diagram);
|
|
@@ -27880,13 +28320,12 @@ function survive(run, target) {
|
|
|
27880
28320
|
const label = nodeLabel(run.diagram, node.id);
|
|
27881
28321
|
const replicas = replicasOf(node);
|
|
27882
28322
|
if (replicas >= 2) {
|
|
27883
|
-
const left =
|
|
27884
|
-
|
|
27885
|
-
if (rho >= 1) {
|
|
28323
|
+
const left = usageOf(run.profiles.get(node.id), replicas - 1, n.readLoadRps, n.writeLoadRps);
|
|
28324
|
+
if (left.saturated) {
|
|
27886
28325
|
failures.push(
|
|
27887
28326
|
fail(
|
|
27888
|
-
`Losing one of ${replicas} ${node.id} replicas leaves ${formatRps(left)} for ${formatRps(n.loadRps)} (${formatPercent(
|
|
27889
|
-
`Add a replica to ${label} (x${replicas + 1}) so the rest carry the load`
|
|
28327
|
+
`Losing one of ${replicas} ${node.id} replicas leaves ${formatRps(left.capacityRps)} for ${formatRps(n.loadRps)} (${formatPercent(left.utilization)})`,
|
|
28328
|
+
writeBound(left) ? saturationHint(run, { ...n, ...left }) : `Add a replica to ${label} (x${replicas + 1}) so the rest carry the load`
|
|
27890
28329
|
)
|
|
27891
28330
|
);
|
|
27892
28331
|
}
|
|
@@ -27911,20 +28350,34 @@ function survive(run, target) {
|
|
|
27911
28350
|
return fail(`${failures[0].message}${more}`, failures[0].hint);
|
|
27912
28351
|
}
|
|
27913
28352
|
function cost(run, max) {
|
|
27914
|
-
const total = run.analysis
|
|
27915
|
-
const
|
|
28353
|
+
const { totalCostUsd: total, totalEgressUsd: egress } = run.analysis;
|
|
28354
|
+
const ofWhich = egress > 0 ? `, ${formatUsd(egress)} of it egress` : "";
|
|
28355
|
+
const message = `Total cost is ${formatUsd(total)}${ofWhich} (limit ${formatUsd(max)})`;
|
|
27916
28356
|
if (total <= max) return pass(message);
|
|
27917
|
-
const
|
|
27918
|
-
|
|
28357
|
+
const usd = (x) => formatUsd(x).replace("/month", "");
|
|
28358
|
+
const top = [...run.analysis.nodes].filter((n) => n.costUsd > 0).sort((a, b) => b.costUsd - a.costUsd).slice(0, 3).map((n) => {
|
|
28359
|
+
const notes = [n.replicas > 1 ? `${n.replicas} replicas` : "", n.shards > 1 ? `${n.shards} shards` : "", n.egressUsd > 0 ? `${usd(n.egressUsd)} of it egress` : ""].filter(Boolean);
|
|
28360
|
+
return `${n.id} ${formatUsd(n.costUsd)}${notes.length ? ` (${notes.join(", ")})` : ""}`;
|
|
28361
|
+
});
|
|
28362
|
+
const egressHint = egress > total / 2 ? " Most of it is egress: serve repeated downloads from a CDN ($0.02/GB) instead of storage ($0.09/GB), or send fewer bytes." : "";
|
|
28363
|
+
return fail(message, `Biggest items: ${top.join(", ")}. Drop replicas on nodes with low utilisation or pick cheaper techs.${egressHint}`);
|
|
27919
28364
|
}
|
|
27920
28365
|
function flowTest(run, test) {
|
|
27921
|
-
const
|
|
27922
|
-
const
|
|
28366
|
+
const checks = test.assertions.map((a) => ({ ...assertion(run, a), loc: a.loc }));
|
|
28367
|
+
const assertions = checks.map(({ passed, message, hint, loc }) => hint === void 0 ? { passed, message, loc } : { passed, message, hint, loc });
|
|
27923
28368
|
if (assertions.length === 0) return { passed: true, message: "No assertions to check", assertions };
|
|
28369
|
+
const failed = checks.filter((a) => !a.passed);
|
|
27924
28370
|
if (failed.length === 0) {
|
|
27925
28371
|
return { passed: true, message: assertions.length === 1 ? assertions[0].message : `All ${assertions.length} assertions hold`, assertions };
|
|
27926
28372
|
}
|
|
27927
|
-
|
|
28373
|
+
const seen = /* @__PURE__ */ new Set();
|
|
28374
|
+
const reported = failed.filter((a) => {
|
|
28375
|
+
const key = a.missing ?? a.message;
|
|
28376
|
+
if (seen.has(key)) return false;
|
|
28377
|
+
seen.add(key);
|
|
28378
|
+
return true;
|
|
28379
|
+
});
|
|
28380
|
+
return { passed: false, message: reported.map((a) => a.message).join("; "), hint: reported[0].hint, assertions };
|
|
27928
28381
|
}
|
|
27929
28382
|
var quote = (s) => `"${s}"`;
|
|
27930
28383
|
function scope(run, useCase, scenario) {
|
|
@@ -27937,9 +28390,13 @@ function scope(run, useCase, scenario) {
|
|
|
27937
28390
|
}
|
|
27938
28391
|
function missingScenario(u, scenario) {
|
|
27939
28392
|
const names = u.scenarios.map((s) => quote(s.name)).join(", ");
|
|
27940
|
-
return
|
|
28393
|
+
return {
|
|
28394
|
+
...fail(`${quote(u.name)} has no scenario ${quote(scenario)}${names ? `; it has ${names}` : ""}`, `Add alt "${scenario}" { \u2026 } to "${u.name}"`),
|
|
28395
|
+
missing: `scenario ${u.name} \u203A ${scenario}`
|
|
28396
|
+
};
|
|
27941
28397
|
}
|
|
27942
28398
|
var isCheck = (x) => "passed" in x;
|
|
28399
|
+
var idsOf = (run, selector) => new Set(selectNodes(run.diagram, selector).map((n) => n.id));
|
|
27943
28400
|
function assertion(run, a) {
|
|
27944
28401
|
switch (a.kind) {
|
|
27945
28402
|
case "calls":
|
|
@@ -27949,9 +28406,9 @@ function assertion(run, a) {
|
|
|
27949
28406
|
case "writesBeforeResponding": {
|
|
27950
28407
|
const sc = scope(run, a.useCase, a.scenario);
|
|
27951
28408
|
if (isCheck(sc)) return sc;
|
|
27952
|
-
const ids =
|
|
28409
|
+
const ids = idsOf(run, a.target);
|
|
27953
28410
|
if (ids.size === 0) return noMatch(a.target);
|
|
27954
|
-
return writesCheck(sc.u, sc.scenarios, (id) => ids.has(id), selectorText(a.target));
|
|
28411
|
+
return writesCheck(run, sc.u, sc.scenarios, (id) => ids.has(id), selectorText(a.target));
|
|
27955
28412
|
}
|
|
27956
28413
|
case "responds":
|
|
27957
28414
|
return responds(run, a);
|
|
@@ -27964,7 +28421,7 @@ function assertion(run, a) {
|
|
|
27964
28421
|
case "handlesFailure": {
|
|
27965
28422
|
const u = findUseCase(run.diagram, a.useCase);
|
|
27966
28423
|
if (!u) return unknownUseCase(run, a.useCase);
|
|
27967
|
-
const ids =
|
|
28424
|
+
const ids = idsOf(run, a.target);
|
|
27968
28425
|
if (ids.size === 0) return noMatch(a.target);
|
|
27969
28426
|
const handled = u.scenarios.find((s) => s.outcome === "success" && s.steps.some((step) => step.failed && ids.has(step.toServiceId)));
|
|
27970
28427
|
const what = selectorText(a.target);
|
|
@@ -27983,6 +28440,14 @@ function assertion(run, a) {
|
|
|
27983
28440
|
if (short.length === 0) return pass(`${nodes.map(has).join(", ")} (minimum ${a.min})`);
|
|
27984
28441
|
return fail(`${short.map(has).join(", ")} (minimum ${a.min})`, `Declare ${short[0].id} with x${a.min}`);
|
|
27985
28442
|
}
|
|
28443
|
+
case "neverWaits":
|
|
28444
|
+
return neverWaits(run, a);
|
|
28445
|
+
case "after":
|
|
28446
|
+
return after(run, a);
|
|
28447
|
+
case "senderCalls":
|
|
28448
|
+
return senderCalls(run, a);
|
|
28449
|
+
case "startsAt":
|
|
28450
|
+
return startsAt(run, a);
|
|
27986
28451
|
}
|
|
27987
28452
|
}
|
|
27988
28453
|
function noMatch(selector) {
|
|
@@ -27992,17 +28457,18 @@ function calls(run, a) {
|
|
|
27992
28457
|
const sc = scope(run, a.useCase, a.scenario);
|
|
27993
28458
|
if (isCheck(sc)) return sc;
|
|
27994
28459
|
const what = selectorText(a.target);
|
|
27995
|
-
const ids =
|
|
27996
|
-
const called = (s) => s.steps.filter((step) => ids.has(step.toServiceId))
|
|
28460
|
+
const ids = idsOf(run, a.target);
|
|
28461
|
+
const called = (s) => s.steps.filter((step) => ids.has(step.toServiceId));
|
|
27997
28462
|
const calling = sc.scenarios.filter((s) => called(s).length > 0);
|
|
27998
28463
|
const names = (list) => list.map((s) => quote(s.name)).join(", ");
|
|
27999
28464
|
const multi = sc.scenarios.length > 1;
|
|
28000
28465
|
if (a.quantifier === "never") {
|
|
28001
28466
|
if (calling.length === 0) return pass(`${sc.label} never calls ${what}`);
|
|
28002
28467
|
const first = calling[0];
|
|
28468
|
+
const step = called(first)[0];
|
|
28003
28469
|
return fail(
|
|
28004
|
-
`${multi ? quote(first.name) : sc.label} calls ${nodeLabel(run.diagram,
|
|
28005
|
-
`Take the call to ${
|
|
28470
|
+
`${multi ? quote(first.name) : sc.label} calls ${nodeLabel(run.diagram, step.toServiceId)}: ${stepText(step)}`,
|
|
28471
|
+
`Take the call to ${step.toServiceId} out of ${quote(first.name)}, e.g. serve it from a cache`
|
|
28006
28472
|
);
|
|
28007
28473
|
}
|
|
28008
28474
|
if (ids.size === 0) return noMatch(a.target);
|
|
@@ -28017,22 +28483,22 @@ function calls(run, a) {
|
|
|
28017
28483
|
function before(run, a) {
|
|
28018
28484
|
const sc = scope(run, a.useCase, a.scenario);
|
|
28019
28485
|
if (isCheck(sc)) return sc;
|
|
28020
|
-
const first =
|
|
28021
|
-
const then =
|
|
28486
|
+
const first = idsOf(run, a.first);
|
|
28487
|
+
const then = idsOf(run, a.then);
|
|
28022
28488
|
const [x, y] = [selectorText(a.first), selectorText(a.then)];
|
|
28023
28489
|
if (first.size === 0) return noMatch(a.first);
|
|
28024
28490
|
if (then.size === 0) return noMatch(a.then);
|
|
28025
28491
|
let checked = 0;
|
|
28026
28492
|
for (const s of sc.scenarios) {
|
|
28027
|
-
const order = requestOrder(sc.u, s).requests
|
|
28028
|
-
const iy = order.findIndex((
|
|
28493
|
+
const order = requestOrder(sc.u, s).requests;
|
|
28494
|
+
const iy = order.findIndex((m) => then.has(m.to));
|
|
28029
28495
|
if (iy < 0) continue;
|
|
28030
28496
|
checked++;
|
|
28031
|
-
const ix = order.findIndex((
|
|
28497
|
+
const ix = order.findIndex((m) => first.has(m.to));
|
|
28032
28498
|
if (ix < 0 || ix > iy) {
|
|
28033
28499
|
const where = sc.scenarios.length > 1 || a.scenario ? `In ${quote(s.name)}` : `In ${quote(sc.u.name)}`;
|
|
28034
28500
|
return fail(
|
|
28035
|
-
ix < 0 ? `${where}, ${order[iy]} is called but ${x} never is` : `${where}, ${order[iy]} is called before ${order[ix]}`,
|
|
28501
|
+
ix < 0 ? `${where}, ${order[iy].to} is called (${stepText(order[iy].step)}) but ${x} never is` : `${where}, ${order[iy].to} is called (${stepText(order[iy].step)}) before ${order[ix].to} (${stepText(order[ix].step)})`,
|
|
28036
28502
|
`Call ${x} before the first call to ${y}`
|
|
28037
28503
|
);
|
|
28038
28504
|
}
|
|
@@ -28040,6 +28506,100 @@ function before(run, a) {
|
|
|
28040
28506
|
if (checked === 0) return fail(`${sc.label} never calls ${y}`, `Add a step to ${y} after the call to ${x}`);
|
|
28041
28507
|
return pass(`${sc.label} calls ${x} before ${y}${checked > 1 ? ` in all ${checked} scenarios that call ${y}` : ""}`);
|
|
28042
28508
|
}
|
|
28509
|
+
function neverWaits(run, a) {
|
|
28510
|
+
const sc = scope(run, a.useCase, a.scenario);
|
|
28511
|
+
if (isCheck(sc)) return sc;
|
|
28512
|
+
const ids = idsOf(run, a.target);
|
|
28513
|
+
const what = selectorText(a.target);
|
|
28514
|
+
for (const s of sc.scenarios) {
|
|
28515
|
+
const hop = criticalPath(sc.u, s).flat().find((h) => !h.async && ids.has(h.target));
|
|
28516
|
+
if (hop) {
|
|
28517
|
+
const where = sc.scenarios.length > 1 ? quote(s.name) : sc.label;
|
|
28518
|
+
return fail(
|
|
28519
|
+
`${where} waits for ${nodeLabel(run.diagram, hop.target)} before responding: ${stepText(hop.step)}${hop.failed ? " times out on the request path" : " is synchronous"}`,
|
|
28520
|
+
`Send it with ->> (through a queue if it must not be lost), or move it after the line that answers the entry request`
|
|
28521
|
+
);
|
|
28522
|
+
}
|
|
28523
|
+
}
|
|
28524
|
+
const calledAtAll = sc.scenarios.some((s) => s.steps.some((step) => ids.has(step.toServiceId)));
|
|
28525
|
+
return pass(`${sc.label} never waits for ${what}${calledAtAll ? "" : ` (it never calls ${what})`}`);
|
|
28526
|
+
}
|
|
28527
|
+
function after(run, a) {
|
|
28528
|
+
const sc = scope(run, a.useCase, a.scenario);
|
|
28529
|
+
if (isCheck(sc)) return sc;
|
|
28530
|
+
const targets = idsOf(run, a.target);
|
|
28531
|
+
const firsts = idsOf(run, a.after);
|
|
28532
|
+
const [y, x] = [selectorText(a.target), selectorText(a.after)];
|
|
28533
|
+
if (targets.size === 0) return noMatch(a.target);
|
|
28534
|
+
if (firsts.size === 0) return noMatch(a.after);
|
|
28535
|
+
let checked = 0;
|
|
28536
|
+
for (const s of sc.scenarios) {
|
|
28537
|
+
const order = requestOrder(sc.u, s).requests;
|
|
28538
|
+
const ix = order.findIndex((m) => firsts.has(m.to));
|
|
28539
|
+
const iy = order.map((m) => targets.has(m.to)).lastIndexOf(true);
|
|
28540
|
+
if (ix < 0 || iy < 0) continue;
|
|
28541
|
+
checked++;
|
|
28542
|
+
if (iy < ix) {
|
|
28543
|
+
const where = sc.scenarios.length > 1 || a.scenario ? `In ${quote(s.name)}` : `In ${quote(sc.u.name)}`;
|
|
28544
|
+
return fail(
|
|
28545
|
+
`${where}, the last call to ${y} (${stepText(order[iy].step)}) comes before the first call to ${x} (${stepText(order[ix].step)})`,
|
|
28546
|
+
`Call ${y} after ${x}: move the step to ${order[iy].to} below the call to ${order[ix].to}`
|
|
28547
|
+
);
|
|
28548
|
+
}
|
|
28549
|
+
}
|
|
28550
|
+
if (checked === 0) {
|
|
28551
|
+
const callsY = sc.scenarios.some((s) => s.steps.some((step) => targets.has(step.toServiceId)));
|
|
28552
|
+
const callsX = sc.scenarios.some((s) => s.steps.some((step) => firsts.has(step.toServiceId)));
|
|
28553
|
+
const why = !callsY && !callsX ? `calls neither ${x} nor ${y}` : !callsY ? `never calls ${y}` : !callsX ? `never calls ${x}` : `never calls ${x} and ${y} in the same scenario`;
|
|
28554
|
+
return fail(`${sc.label} ${why}`, `Add a step to ${y} after the call to ${x}`);
|
|
28555
|
+
}
|
|
28556
|
+
return pass(`${sc.label} calls ${y} after ${x}${checked > 1 ? ` in all ${checked} scenarios that call both` : ""}`);
|
|
28557
|
+
}
|
|
28558
|
+
function senderCalls(run, a) {
|
|
28559
|
+
let useCases = run.diagram.useCases;
|
|
28560
|
+
if (a.useCase !== void 0) {
|
|
28561
|
+
const u = findUseCase(run.diagram, a.useCase);
|
|
28562
|
+
if (!u) return unknownUseCase(run, a.useCase);
|
|
28563
|
+
useCases = [u];
|
|
28564
|
+
}
|
|
28565
|
+
const from = idsOf(run, a.from);
|
|
28566
|
+
const to = idsOf(run, a.to);
|
|
28567
|
+
const [x, y] = [selectorText(a.from), selectorText(a.to)];
|
|
28568
|
+
const within = a.useCase !== void 0 ? ` in ${quote(useCases[0].name)}` : "";
|
|
28569
|
+
for (const u of useCases) {
|
|
28570
|
+
for (const s of u.scenarios) {
|
|
28571
|
+
const step = s.steps.find((st) => from.has(st.fromServiceId) && to.has(st.toServiceId));
|
|
28572
|
+
if (!step) continue;
|
|
28573
|
+
const where = u.scenarios.length > 1 ? `${quote(u.name)} scenario ${quote(s.name)}` : quote(u.name);
|
|
28574
|
+
if (a.quantifier === "some") return pass(`${x} calls ${y}${within}: ${stepText(step)} in ${where}`);
|
|
28575
|
+
return fail(
|
|
28576
|
+
`${x} calls ${y}${within}: ${stepText(step)} in ${where}`,
|
|
28577
|
+
`Let another node make this call (e.g. the client uploads to storage with a presigned URL), or remove the step`
|
|
28578
|
+
);
|
|
28579
|
+
}
|
|
28580
|
+
}
|
|
28581
|
+
if (a.quantifier === "never") return pass(`${x} never calls ${y}${within}`);
|
|
28582
|
+
if (from.size === 0) return noMatch(a.from);
|
|
28583
|
+
if (to.size === 0) return noMatch(a.to);
|
|
28584
|
+
return fail(`No step is sent by ${x} to ${y}${within}`, `Add a step ${[...from][0]} -> ${[...to][0]}${within}`);
|
|
28585
|
+
}
|
|
28586
|
+
function startsAt(run, a) {
|
|
28587
|
+
const u = findUseCase(run.diagram, a.useCase);
|
|
28588
|
+
if (!u) return unknownUseCase(run, a.useCase);
|
|
28589
|
+
const ids = idsOf(run, a.target);
|
|
28590
|
+
const what = selectorText(a.target);
|
|
28591
|
+
if (ids.size === 0) return noMatch(a.target);
|
|
28592
|
+
const entries = u.scenarios.map((s) => s.steps[0]).filter((s) => !!s);
|
|
28593
|
+
if (entries.length === 0) return fail(`${quote(u.name)} has no steps`, `Start "${u.name}" with a step sent by ${what}`);
|
|
28594
|
+
const wrong = entries.find((s) => !ids.has(s.fromServiceId));
|
|
28595
|
+
if (wrong) {
|
|
28596
|
+
return fail(
|
|
28597
|
+
`${quote(u.name)} starts at ${nodeLabel(run.diagram, wrong.fromServiceId)}, not ${what}: ${stepText(wrong)}`,
|
|
28598
|
+
`Make the first step of "${u.name}" a step sent by ${what}`
|
|
28599
|
+
);
|
|
28600
|
+
}
|
|
28601
|
+
return pass(`${quote(u.name)} starts at ${nodeLabel(run.diagram, entries[0].fromServiceId)}`);
|
|
28602
|
+
}
|
|
28043
28603
|
function statusMatches(status, wanted) {
|
|
28044
28604
|
if (status === void 0) return false;
|
|
28045
28605
|
const cls = /^([1-5])xx$/i.exec(wanted);
|
|
@@ -28054,19 +28614,18 @@ function responds(run, a) {
|
|
|
28054
28614
|
return fail(`${sc.label} never responds ${a.status}; it answers ${seen.join(", ") || "nothing"}`, `Add a scenario (alt) whose entry request is answered with ${a.status}`);
|
|
28055
28615
|
}
|
|
28056
28616
|
function noPath(run, from, to) {
|
|
28057
|
-
const sources =
|
|
28058
|
-
const targets =
|
|
28617
|
+
const sources = idsOf(run, from);
|
|
28618
|
+
const targets = idsOf(run, to);
|
|
28059
28619
|
const [x, y] = [selectorText(from), selectorText(to)];
|
|
28060
|
-
const line = (loc) => `${loc.file ? `${loc.file}:` : "line "}${loc.line}`;
|
|
28061
28620
|
const hint = `Route it through a node that guards ${y} (a gateway or a service) instead of going there directly`;
|
|
28062
28621
|
const edge = run.diagram.edges.find((e) => sources.has(e.source) && targets.has(e.target));
|
|
28063
|
-
if (edge) return fail(`The connection ${edge.source} -> ${edge.target} (${
|
|
28622
|
+
if (edge) return fail(`The connection ${edge.source} -> ${edge.target} (${lineOf(edge.loc)}) goes directly from ${x} to ${y}`, hint);
|
|
28064
28623
|
for (const u of run.diagram.useCases) {
|
|
28065
28624
|
for (const s of u.scenarios) {
|
|
28066
28625
|
const step = s.steps.find((st) => sources.has(st.fromServiceId) && targets.has(st.toServiceId));
|
|
28067
28626
|
if (step) {
|
|
28068
28627
|
const where = u.scenarios.length > 1 ? `${quote(u.name)} scenario ${quote(s.name)}` : quote(u.name);
|
|
28069
|
-
return fail(`The step ${step.fromServiceId} -> ${step.toServiceId} in ${where} (${
|
|
28628
|
+
return fail(`The step ${step.fromServiceId} -> ${step.toServiceId} in ${where} (${lineOf(step.loc)}) goes directly from ${x} to ${y}`, hint);
|
|
28070
28629
|
}
|
|
28071
28630
|
}
|
|
28072
28631
|
}
|
|
@@ -28089,6 +28648,24 @@ var KEYWORDS2 = [
|
|
|
28089
28648
|
{ label: "decision", detail: "A design decision with its reasons", snippet: 'decision "${1:Title}" {\n because "${2:reason}"$0\n}' },
|
|
28090
28649
|
{ label: "test", detail: 'Flow assertions: "Use case" calls any cache', snippet: 'test "${1:Name}" {\n $0\n}' }
|
|
28091
28650
|
];
|
|
28651
|
+
var ASSERTIONS = [
|
|
28652
|
+
{ label: '"Use case" calls', detail: "Some scenario calls the node", snippet: '"${1:Use case}" calls ${2:node}' },
|
|
28653
|
+
{ label: '"Use case" never calls', detail: "No scenario calls the node", snippet: '"${1:Use case}" never calls ${2:node}' },
|
|
28654
|
+
{ label: '"Use case" every scenario calls', detail: "Every scenario calls the node", snippet: '"${1:Use case}" every scenario calls ${2:node}' },
|
|
28655
|
+
{ label: '"Use case" calls \u2026 before \u2026', detail: "The first call to one node comes before the first call to another", snippet: '"${1:Use case}" calls ${2:node} before ${3:node}' },
|
|
28656
|
+
{ label: '"Use case" calls \u2026 after \u2026', detail: "The last call to one node comes after the first call to another", snippet: '"${1:Use case}" calls ${2:node} after ${3:node}' },
|
|
28657
|
+
{ label: '"Use case" never waits for', detail: "No synchronous call to the node before the response", snippet: '"${1:Use case}" never waits for ${2:node}' },
|
|
28658
|
+
{ label: '"Use case" writes \u2026 before responding', detail: "A write to the node happens before the response", snippet: '"${1:Use case}" writes ${2:node} before responding' },
|
|
28659
|
+
{ label: '"Use case" responds', detail: "Status code or class of the entry response", snippet: '"${1:Use case}" responds ${2:2xx}' },
|
|
28660
|
+
{ label: '"Use case" starts at', detail: "The entry request is sent by the node", snippet: '"${1:Use case}" starts at ${2:node}' },
|
|
28661
|
+
{ label: '"Use case" has scenario', detail: "The use case has a scenario of this name", snippet: '"${1:Use case}" has scenario "${2:Scenario}"' },
|
|
28662
|
+
{ label: '"Use case" handles failure of', detail: "A scenario fails the node and still answers", snippet: '"${1:Use case}" handles failure of ${2:node}' },
|
|
28663
|
+
{ label: "node calls node", detail: "Some step is sent by one node to the other", snippet: "${1:node} calls ${2:node}" },
|
|
28664
|
+
{ label: "node never calls node", detail: "No step is sent by one node to the other", snippet: "${1:node} never calls ${2:node}" },
|
|
28665
|
+
{ label: 'in "Use case" node calls node', detail: "A step of the use case is sent by one node to the other", snippet: 'in "${1:Use case}" ${2:node} calls ${3:node}' },
|
|
28666
|
+
{ label: "no path from \u2026 to \u2026", detail: "No chain of connections links the nodes", snippet: "no path from ${1:node} to ${2:node}" },
|
|
28667
|
+
{ label: "node has replicas >=", detail: "Minimum replica count", snippet: "${1:node} has replicas >= ${2:2}" }
|
|
28668
|
+
];
|
|
28092
28669
|
|
|
28093
28670
|
// src/imports.ts
|
|
28094
28671
|
var import_node_fs3 = require("node:fs");
|
|
@@ -28135,6 +28712,10 @@ function importLinks(result) {
|
|
|
28135
28712
|
}
|
|
28136
28713
|
|
|
28137
28714
|
// src/simulation.ts
|
|
28715
|
+
function formatGb(gb) {
|
|
28716
|
+
const n = (x, digits) => Number(x.toFixed(digits)).toLocaleString("en-US");
|
|
28717
|
+
return gb >= 1e3 ? `${n(gb / 1e3, 1)} TB` : `${n(gb, gb < 10 ? 1 : 0)} GB`;
|
|
28718
|
+
}
|
|
28138
28719
|
function testDiagnostics(diagram) {
|
|
28139
28720
|
const out = [];
|
|
28140
28721
|
for (const r of runTests(diagram)) {
|
|
@@ -28153,9 +28734,12 @@ function nodeSimulation(diagram, nodeId) {
|
|
|
28153
28734
|
const n = analyze(diagram).nodes.find((x) => x.id === nodeId);
|
|
28154
28735
|
if (!n || n.kind === "client" || n.kind === "other") return void 0;
|
|
28155
28736
|
const state = n.saturated ? " \u2014 **saturated**" : n.utilization > HOT ? " \u2014 hot" : "";
|
|
28737
|
+
const split2 = n.readLoadRps > 0 && n.writeLoadRps > 0 ? [`Reads ${formatRps(n.readLoadRps)} of ${formatRps(n.readCapacityRps)} (${formatPercent(n.readUtilization)}) \xB7 writes ${formatRps(n.writeLoadRps)} of ${formatRps(n.writeCapacityRps)} (${formatPercent(n.writeUtilization)})`] : [];
|
|
28738
|
+
const egress = n.egressGbPerMonth > 0 ? ` (${formatUsd(n.egressUsd).replace("/month", "")} of it egress, ${formatGb(n.egressGbPerMonth)})` : "";
|
|
28156
28739
|
return [
|
|
28157
28740
|
`Load ${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)} (${formatPercent(n.utilization)} utilised${state})`,
|
|
28158
|
-
|
|
28741
|
+
...split2,
|
|
28742
|
+
`Latency ${formatMs(n.latencyMs)} per call \xB7 availability ${formatAvailability(n.availability)} \xB7 ${formatUsd(n.costUsd)}${n.replicas > 1 ? ` for ${n.replicas} replicas` : ""}${egress}`
|
|
28159
28743
|
].join(" \n");
|
|
28160
28744
|
}
|
|
28161
28745
|
|
|
@@ -28239,6 +28823,10 @@ function complete(analysis, pos) {
|
|
|
28239
28823
|
range
|
|
28240
28824
|
}));
|
|
28241
28825
|
if (!atLineStart) return nodes;
|
|
28826
|
+
if (inTestBlock(analysis.lines, pos.line) && !/\}/.test(before2)) {
|
|
28827
|
+
const assertions = ASSERTIONS.map((a) => ({ label: a.label, kind: "keyword", detail: a.detail, snippet: a.snippet, range }));
|
|
28828
|
+
return [...assertions, ...nodes];
|
|
28829
|
+
}
|
|
28242
28830
|
const afterBrace = /\}\s*$/.test(before2.slice(0, before2.length - word.length));
|
|
28243
28831
|
const keywords = KEYWORDS2.filter((k) => !afterBrace || k.label === "alt").map((k) => ({
|
|
28244
28832
|
label: k.label,
|
|
@@ -28249,6 +28837,14 @@ function complete(analysis, pos) {
|
|
|
28249
28837
|
}));
|
|
28250
28838
|
return afterBrace ? keywords : [...keywords, ...nodes];
|
|
28251
28839
|
}
|
|
28840
|
+
function inTestBlock(lines, index) {
|
|
28841
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
28842
|
+
const line = lines[i];
|
|
28843
|
+
if (/^\s*\}/.test(line)) return false;
|
|
28844
|
+
if (/\{\s*(?:#.*)?$/.test(line)) return /^\s*test\s+"/.test(line);
|
|
28845
|
+
}
|
|
28846
|
+
return false;
|
|
28847
|
+
}
|
|
28252
28848
|
function describeNode(n) {
|
|
28253
28849
|
return [n.name !== n.id ? n.name : "", n.techStack, n.ownerTeam ? `@${n.ownerTeam}` : ""].filter(Boolean).join(" \xB7 ");
|
|
28254
28850
|
}
|
|
@@ -28361,12 +28957,12 @@ function quickFix(analysis, message) {
|
|
|
28361
28957
|
if (updated === text) return null;
|
|
28362
28958
|
return { title: `Add connection '${from} -> ${to}'`, edit: diffEdit(text, updated) };
|
|
28363
28959
|
}
|
|
28364
|
-
function diffEdit(before2,
|
|
28960
|
+
function diffEdit(before2, after2) {
|
|
28365
28961
|
let start = 0;
|
|
28366
|
-
while (start < before2.length && start <
|
|
28962
|
+
while (start < before2.length && start < after2.length && before2[start] === after2[start]) start++;
|
|
28367
28963
|
let end = 0;
|
|
28368
|
-
while (end < before2.length - start && end <
|
|
28369
|
-
return { range: { start: positionAt(before2, start), end: positionAt(before2, before2.length - end) }, newText:
|
|
28964
|
+
while (end < before2.length - start && end < after2.length - start && before2[before2.length - 1 - end] === after2[after2.length - 1 - end]) end++;
|
|
28965
|
+
return { range: { start: positionAt(before2, start), end: positionAt(before2, before2.length - end) }, newText: after2.slice(start, after2.length - end) };
|
|
28370
28966
|
}
|
|
28371
28967
|
function positionAt(text, offset) {
|
|
28372
28968
|
const before2 = text.slice(0, offset).split("\n");
|
|
@@ -28375,7 +28971,7 @@ function positionAt(text, offset) {
|
|
|
28375
28971
|
|
|
28376
28972
|
// src/server.ts
|
|
28377
28973
|
if (process.argv.includes("--version")) {
|
|
28378
|
-
console.log(true ? "0.
|
|
28974
|
+
console.log(true ? "0.5.0" : "dev");
|
|
28379
28975
|
process.exit(0);
|
|
28380
28976
|
}
|
|
28381
28977
|
var connection = (0, import_node.createConnection)(import_node.ProposedFeatures.all);
|
|
@@ -28416,7 +29012,7 @@ connection.onInitialize(() => ({
|
|
|
28416
29012
|
documentFormattingProvider: true,
|
|
28417
29013
|
codeActionProvider: { codeActionKinds: [import_node.CodeActionKind.QuickFix] }
|
|
28418
29014
|
},
|
|
28419
|
-
serverInfo: { name: "proschi-language-server", version: true ? "0.
|
|
29015
|
+
serverInfo: { name: "proschi-language-server", version: true ? "0.5.0" : "dev" }
|
|
28420
29016
|
}));
|
|
28421
29017
|
function validate(document) {
|
|
28422
29018
|
const analysis = analysisOf(document);
|