proschi 0.3.0 → 0.4.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/cli.cjs +763 -192
- 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/cli.cjs
CHANGED
|
@@ -15337,7 +15337,7 @@ var IDENT_PART = /[A-Za-z0-9_]/;
|
|
|
15337
15337
|
var TEAM_PART = /[A-Za-z0-9_-]/;
|
|
15338
15338
|
var DIGIT = /[0-9]/;
|
|
15339
15339
|
var LETTER = /[A-Za-z]/;
|
|
15340
|
-
var SEPARATE_UNIT = /^[ \t]+(rps|rpm|rpd|ms|s|%|usd\/month)(?![A-Za-z0-9_/])/;
|
|
15340
|
+
var SEPARATE_UNIT = /^[ \t]+(rps|rpm|rpd|ms|s|%|usd\/month|usd\/GB|MB\/s|GB\/s)(?![A-Za-z0-9_/])/;
|
|
15341
15341
|
function tokenizeLine(text, line) {
|
|
15342
15342
|
const tokens = [];
|
|
15343
15343
|
const diagnostics = [];
|
|
@@ -15529,11 +15529,21 @@ function endpointGroupKey(endpoint, all) {
|
|
|
15529
15529
|
}
|
|
15530
15530
|
|
|
15531
15531
|
// ../frontend/src/dsl/kinds.ts
|
|
15532
|
-
var KINDS = ["client", "edge", "service", "function", "cache", "database", "search", "analytics", "queue", "storage", "external", "other"];
|
|
15532
|
+
var KINDS = ["client", "edge", "cdn", "loadbalancer", "gateway", "dns", "service", "function", "cache", "database", "search", "analytics", "queue", "storage", "external", "other"];
|
|
15533
15533
|
var DATA_STORE_KINDS = ["cache", "database", "search", "analytics", "queue", "storage"];
|
|
15534
|
+
var EDGE_KINDS = ["edge", "cdn", "loadbalancer", "gateway", "dns"];
|
|
15534
15535
|
var BY_TECH = {
|
|
15535
|
-
"AWS
|
|
15536
|
-
"Azure
|
|
15536
|
+
"AWS CloudFront": "cdn",
|
|
15537
|
+
"Azure CDN": "cdn",
|
|
15538
|
+
"GCP Cloud CDN": "cdn",
|
|
15539
|
+
"Azure Front Door": "cdn",
|
|
15540
|
+
"AWS Load Balancer": "loadbalancer",
|
|
15541
|
+
"GCP Load Balancing": "loadbalancer",
|
|
15542
|
+
"AWS API Gateway": "gateway",
|
|
15543
|
+
"Azure API Management": "gateway",
|
|
15544
|
+
"AWS Route53": "dns",
|
|
15545
|
+
"Azure DNS": "dns",
|
|
15546
|
+
"GCP Cloud DNS": "dns",
|
|
15537
15547
|
Redis: "cache",
|
|
15538
15548
|
Elasticsearch: "search",
|
|
15539
15549
|
"GCP BigQuery": "analytics",
|
|
@@ -15565,6 +15575,12 @@ function isKind(word) {
|
|
|
15565
15575
|
function isDataStore(kind) {
|
|
15566
15576
|
return DATA_STORE_KINDS.includes(kind);
|
|
15567
15577
|
}
|
|
15578
|
+
function isEdge(kind) {
|
|
15579
|
+
return EDGE_KINDS.includes(kind);
|
|
15580
|
+
}
|
|
15581
|
+
function kindMatches(actual, wanted) {
|
|
15582
|
+
return actual === wanted || wanted === "edge" && isEdge(actual);
|
|
15583
|
+
}
|
|
15568
15584
|
|
|
15569
15585
|
// ../frontend/src/dsl/quantity.ts
|
|
15570
15586
|
function parseQuantity(text) {
|
|
@@ -15576,11 +15592,11 @@ function parseQuantity(text) {
|
|
|
15576
15592
|
if (suffix === "" || unit) return unit ? { value: round(number * unit.factor), unit: unit.unit } : { value: number };
|
|
15577
15593
|
const magnitude = MAGNITUDES.get(suffix[0]);
|
|
15578
15594
|
const rest = suffix.slice(1);
|
|
15579
|
-
const
|
|
15580
|
-
if (magnitude && (rest === "" ||
|
|
15581
|
-
return
|
|
15595
|
+
const after2 = UNITS.get(rest);
|
|
15596
|
+
if (magnitude && (rest === "" || after2)) {
|
|
15597
|
+
return after2 ? { value: round(number * magnitude * after2.factor), unit: after2.unit } : { value: round(number * magnitude) };
|
|
15582
15598
|
}
|
|
15583
|
-
return { error: `Unknown unit '${suffix}' in '${text}'; use rps, rpm, rpd, ms, s,
|
|
15599
|
+
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)` };
|
|
15584
15600
|
}
|
|
15585
15601
|
var round = (n) => Number(n.toPrecision(12));
|
|
15586
15602
|
var MAGNITUDES = /* @__PURE__ */ new Map([
|
|
@@ -15595,9 +15611,67 @@ var UNITS = /* @__PURE__ */ new Map([
|
|
|
15595
15611
|
["ms", { unit: "ms", factor: 1 }],
|
|
15596
15612
|
["s", { unit: "ms", factor: 1e3 }],
|
|
15597
15613
|
["%", { unit: "%", factor: 1 }],
|
|
15598
|
-
["usd/month", { unit: "usd/month", factor: 1 }]
|
|
15614
|
+
["usd/month", { unit: "usd/month", factor: 1 }],
|
|
15615
|
+
["MB/s", { unit: "MBps", factor: 1 }],
|
|
15616
|
+
["GB/s", { unit: "MBps", factor: 1e3 }],
|
|
15617
|
+
["usd/GB", { unit: "usd/GB", factor: 1 }]
|
|
15599
15618
|
]);
|
|
15600
15619
|
|
|
15620
|
+
// ../frontend/src/dsl/access.ts
|
|
15621
|
+
var WRITE_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
|
|
15622
|
+
var WRITE_VERBS = [
|
|
15623
|
+
"INSERT",
|
|
15624
|
+
"UPDATE",
|
|
15625
|
+
"UPSERT",
|
|
15626
|
+
"DELETE",
|
|
15627
|
+
"PUT",
|
|
15628
|
+
"SET",
|
|
15629
|
+
"WRITE",
|
|
15630
|
+
"APPEND",
|
|
15631
|
+
"INCR",
|
|
15632
|
+
"DECR",
|
|
15633
|
+
"LPUSH",
|
|
15634
|
+
"RPUSH",
|
|
15635
|
+
"ZADD",
|
|
15636
|
+
"HSET",
|
|
15637
|
+
"GEOADD",
|
|
15638
|
+
"PUBLISH",
|
|
15639
|
+
"SEND",
|
|
15640
|
+
"ENQUEUE",
|
|
15641
|
+
"PRODUCE",
|
|
15642
|
+
"CHARGE",
|
|
15643
|
+
"CREATE",
|
|
15644
|
+
// DynamoDB and Redis commands that store data
|
|
15645
|
+
"PUTITEM",
|
|
15646
|
+
"UPDATEITEM",
|
|
15647
|
+
"DELETEITEM",
|
|
15648
|
+
"BATCHWRITEITEM",
|
|
15649
|
+
"INCRBY",
|
|
15650
|
+
"HINCRBY",
|
|
15651
|
+
"SADD",
|
|
15652
|
+
"XADD",
|
|
15653
|
+
"MSET",
|
|
15654
|
+
// Plain words for storing something
|
|
15655
|
+
"SAVE",
|
|
15656
|
+
"STORE",
|
|
15657
|
+
"UPLOAD",
|
|
15658
|
+
"COMMIT",
|
|
15659
|
+
"RECORD",
|
|
15660
|
+
"MARK",
|
|
15661
|
+
// Claiming a resource changes state: a seat, a driver, stock
|
|
15662
|
+
"RESERVE",
|
|
15663
|
+
"HOLD",
|
|
15664
|
+
"BOOK",
|
|
15665
|
+
// Handing something on: a write at the receiver (a queue, a provider), like SEND and PUBLISH
|
|
15666
|
+
"EMIT",
|
|
15667
|
+
"NOTIFY"
|
|
15668
|
+
];
|
|
15669
|
+
function accessOf(httpMethod, label) {
|
|
15670
|
+
if (httpMethod) return WRITE_METHODS.includes(httpMethod.toUpperCase()) ? "write" : "read";
|
|
15671
|
+
const word = /^\s*"?([A-Za-z_]+)/.exec(label)?.[1];
|
|
15672
|
+
return word && WRITE_VERBS.includes(word.toUpperCase()) ? "write" : "read";
|
|
15673
|
+
}
|
|
15674
|
+
|
|
15601
15675
|
// ../frontend/src/dsl/parser.ts
|
|
15602
15676
|
var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
15603
15677
|
var DEFAULT_TECH = "Rectangle";
|
|
@@ -15610,10 +15684,13 @@ var UNIT_EXAMPLES = {
|
|
|
15610
15684
|
rps: "a rate, e.g. 100k rps, 6k rpm or 1m rpd",
|
|
15611
15685
|
ms: "a duration, e.g. 50ms or 1.5s",
|
|
15612
15686
|
"%": "a percentage, e.g. 99.9%",
|
|
15613
|
-
"usd/month": "a monthly cost, e.g. 400 usd/month"
|
|
15687
|
+
"usd/month": "a monthly cost, e.g. 400 usd/month",
|
|
15688
|
+
MBps: "a bandwidth, e.g. 500 MB/s or 1 GB/s",
|
|
15689
|
+
"usd/GB": "a price per gigabyte, e.g. 0.05 usd/GB"
|
|
15614
15690
|
};
|
|
15615
|
-
var
|
|
15616
|
-
var
|
|
15691
|
+
var CAPACITY_PARTS = "a rate, reads, writes, shards, latency, availability, cost, durable or volatile, consistency, bandwidth or egress";
|
|
15692
|
+
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';
|
|
15693
|
+
var ASSERTION_VERBS = "calls, every scenario calls, never calls, never waits for, writes, responds, starts at, has scenario or handles failure of";
|
|
15617
15694
|
var techByName = new Map(
|
|
15618
15695
|
componentCatalog.map((c) => [c.techStack.toLowerCase(), { type: c.type, techStack: c.techStack }])
|
|
15619
15696
|
);
|
|
@@ -16044,7 +16121,20 @@ var Parser = class {
|
|
|
16044
16121
|
if (top?.kind === "usecase" || top?.kind === "alt" || top?.kind === "par") {
|
|
16045
16122
|
const body = this.currentBody();
|
|
16046
16123
|
body.openAlt = void 0;
|
|
16047
|
-
|
|
16124
|
+
const prefixes = arrow === "-->" ? { rest: label } : this.readLabelPrefixes(label, labelToken, line);
|
|
16125
|
+
body.items.push({
|
|
16126
|
+
kind: "step",
|
|
16127
|
+
step: {
|
|
16128
|
+
from,
|
|
16129
|
+
to,
|
|
16130
|
+
arrow,
|
|
16131
|
+
label: prefixes.rest,
|
|
16132
|
+
parallelGroup: top.kind === "par" ? top.group : void 0,
|
|
16133
|
+
...prefixes.multiplier !== void 0 ? { multiplier: prefixes.multiplier } : {},
|
|
16134
|
+
...prefixes.sizeBytes !== void 0 ? { sizeBytes: prefixes.sizeBytes } : {},
|
|
16135
|
+
loc
|
|
16136
|
+
}
|
|
16137
|
+
});
|
|
16048
16138
|
} else if (arrow === "-x") {
|
|
16049
16139
|
this.error("'-x' marks a failed call and is only allowed in use case steps", this.locOf(arrowToken, line));
|
|
16050
16140
|
} else {
|
|
@@ -16054,6 +16144,42 @@ var Parser = class {
|
|
|
16054
16144
|
}
|
|
16055
16145
|
return lastLine;
|
|
16056
16146
|
}
|
|
16147
|
+
/**
|
|
16148
|
+
* Reads `x<N>` (fan-out) and `~<size>` (payload size) at the start of a step
|
|
16149
|
+
* label, in either order (§7.3), and returns the label without them. A
|
|
16150
|
+
* prefix must be followed by whitespace or end the label, so `xml …` and
|
|
16151
|
+
* `x-request-id` are ordinary labels. Problems are reported at the prefix.
|
|
16152
|
+
*/
|
|
16153
|
+
readLabelPrefixes(label, token, line) {
|
|
16154
|
+
const out = { rest: label };
|
|
16155
|
+
if (!token) return out;
|
|
16156
|
+
const after2 = this.lines[line - 1].slice(token.col);
|
|
16157
|
+
const base = token.col + 1 + (after2.length - after2.trimStart().length);
|
|
16158
|
+
let offset = 0;
|
|
16159
|
+
for (; ; ) {
|
|
16160
|
+
const rest = label.slice(offset);
|
|
16161
|
+
const at = (length) => ({ line, col: base + offset, length, ...this.fileField() });
|
|
16162
|
+
const fanOut = /^x(\d+)(?=\s|$)/.exec(rest);
|
|
16163
|
+
const size = rest.startsWith("~") ? /^~(\S*)/.exec(rest) : null;
|
|
16164
|
+
const match = fanOut ?? size;
|
|
16165
|
+
if (!match) break;
|
|
16166
|
+
if (fanOut) {
|
|
16167
|
+
const n = Number(fanOut[1]);
|
|
16168
|
+
if (out.multiplier !== void 0) this.error("The fan-out is given twice; write one x<N>", at(match[0].length));
|
|
16169
|
+
else if (n < 1) this.error("A fan-out needs at least one call per request: x1, x2, \u2026", at(match[0].length));
|
|
16170
|
+
else out.multiplier = n;
|
|
16171
|
+
} else {
|
|
16172
|
+
const bytes = parseSize(size[1]);
|
|
16173
|
+
if (out.sizeBytes !== void 0) this.error("The payload size is given twice; write one ~<size>", at(match[0].length));
|
|
16174
|
+
else if ("error" in bytes) this.error(bytes.error, at(match[0].length));
|
|
16175
|
+
else out.sizeBytes = bytes.value;
|
|
16176
|
+
}
|
|
16177
|
+
offset += match[0].length;
|
|
16178
|
+
offset += label.slice(offset).length - label.slice(offset).trimStart().length;
|
|
16179
|
+
}
|
|
16180
|
+
out.rest = label.slice(offset);
|
|
16181
|
+
return out;
|
|
16182
|
+
}
|
|
16057
16183
|
// ---- HLD sections: traffic, requirements, capacity, entity, decision, test (docs/design/hld-and-practice.md §1) ----
|
|
16058
16184
|
/**
|
|
16059
16185
|
* Starts a section block. Sections are top level only; a misplaced one, or
|
|
@@ -16177,10 +16303,14 @@ var Parser = class {
|
|
|
16177
16303
|
useCase = { name: tokens[i].value, loc: this.locOf(tokens[i], line) };
|
|
16178
16304
|
i++;
|
|
16179
16305
|
};
|
|
16180
|
-
const commit = (requirement2) => {
|
|
16306
|
+
const commit = (requirement2, scenario) => {
|
|
16181
16307
|
this.requirements.push(requirement2);
|
|
16182
16308
|
const named = useCase;
|
|
16183
|
-
if (named)
|
|
16309
|
+
if (named)
|
|
16310
|
+
this.deferred.push(() => {
|
|
16311
|
+
const found = this.checkUseCase(named.name, named.loc);
|
|
16312
|
+
if (found && scenario) this.checkScenario(found, scenario.name, scenario.loc);
|
|
16313
|
+
});
|
|
16184
16314
|
};
|
|
16185
16315
|
const withUseCase = () => useCase ? { useCase: useCase.name } : {};
|
|
16186
16316
|
if (/^p\d+$/.test(word)) {
|
|
@@ -16190,10 +16320,24 @@ var Parser = class {
|
|
|
16190
16320
|
return;
|
|
16191
16321
|
}
|
|
16192
16322
|
readUseCase();
|
|
16323
|
+
let scenario;
|
|
16324
|
+
if (isWord(tokens[i], "scenario")) {
|
|
16325
|
+
const name = tokens[i + 1];
|
|
16326
|
+
if (!useCase) {
|
|
16327
|
+
this.error(`A scenario belongs to a use case; write ${word} "Use case" scenario "Scenario" < 100ms`, this.locOf(tokens[i], line));
|
|
16328
|
+
return;
|
|
16329
|
+
}
|
|
16330
|
+
if (name?.kind !== "string") {
|
|
16331
|
+
this.error("Expected a scenario name in quotes after scenario", this.locOf(name ?? tokens[i], line));
|
|
16332
|
+
return;
|
|
16333
|
+
}
|
|
16334
|
+
scenario = { name: name.value, loc: this.locOf(name, line) };
|
|
16335
|
+
i += 2;
|
|
16336
|
+
}
|
|
16193
16337
|
if (!this.expectOp(tokens, i, ["<", "<="], `${word} "Use case" < 50ms`, line)) return;
|
|
16194
16338
|
const maxMs = this.quantityOf(tokens[i + 1], "ms", tokens[i], line);
|
|
16195
16339
|
if (maxMs === void 0 || !this.expectEnd(tokens, i + 2, line)) return;
|
|
16196
|
-
commit({ kind: "latency", percentile, ...withUseCase(), maxMs, loc });
|
|
16340
|
+
commit({ kind: "latency", percentile, ...withUseCase(), ...scenario ? { scenario: scenario.name } : {}, maxMs, loc }, scenario);
|
|
16197
16341
|
return;
|
|
16198
16342
|
}
|
|
16199
16343
|
switch (word) {
|
|
@@ -16276,8 +16420,50 @@ var Parser = class {
|
|
|
16276
16420
|
part = "durable or volatile";
|
|
16277
16421
|
override.durable = t.value === "durable";
|
|
16278
16422
|
i++;
|
|
16423
|
+
} else if (isWord(t, "reads") || isWord(t, "writes")) {
|
|
16424
|
+
part = t.value;
|
|
16425
|
+
const value = this.quantityOf(tokens[i + 1], "rps", t, line);
|
|
16426
|
+
if (value === void 0) return;
|
|
16427
|
+
if (part === "reads") override.readRps = value;
|
|
16428
|
+
else override.writeRps = value;
|
|
16429
|
+
i += 2;
|
|
16430
|
+
} else if (isWord(t, "shards")) {
|
|
16431
|
+
part = "shards";
|
|
16432
|
+
const n = tokens[i + 1];
|
|
16433
|
+
if (n?.kind !== "number" || Number(n.value) < 1) {
|
|
16434
|
+
this.error("Expected a whole number of shards, at least 1, e.g. shards 4", this.locOf(n ?? t, line));
|
|
16435
|
+
return;
|
|
16436
|
+
}
|
|
16437
|
+
override.shards = Number(n.value);
|
|
16438
|
+
i += 2;
|
|
16439
|
+
} else if (isWord(t, "consistency")) {
|
|
16440
|
+
part = "consistency";
|
|
16441
|
+
const value = tokens[i + 1];
|
|
16442
|
+
if (!isWord(value, "strong") && !isWord(value, "eventual")) {
|
|
16443
|
+
this.error(`Expected strong or eventual after consistency${value ? `, not ${describe(value)}` : ""}`, this.locOf(value ?? t, line));
|
|
16444
|
+
return;
|
|
16445
|
+
}
|
|
16446
|
+
override.consistency = value.value;
|
|
16447
|
+
i += 2;
|
|
16448
|
+
} else if (isWord(t, "bandwidth") || isWord(t, "egress")) {
|
|
16449
|
+
part = t.value;
|
|
16450
|
+
const value = this.quantityOf(tokens[i + 1], part === "bandwidth" ? "MBps" : "usd/GB", t, line);
|
|
16451
|
+
if (value === void 0) return;
|
|
16452
|
+
if (part === "bandwidth") {
|
|
16453
|
+
if (value <= 0) {
|
|
16454
|
+
this.error("Bandwidth must be more than 0", this.locOf(tokens[i + 1], line));
|
|
16455
|
+
return;
|
|
16456
|
+
}
|
|
16457
|
+
override.bandwidthMBps = value;
|
|
16458
|
+
} else override.egressUsdPerGb = value;
|
|
16459
|
+
i += 2;
|
|
16279
16460
|
} else {
|
|
16280
|
-
this.error(`Unexpected ${describe(t)}; expected
|
|
16461
|
+
this.error(`Unexpected ${describe(t)}; expected ${CAPACITY_PARTS}`, this.locOf(t, line));
|
|
16462
|
+
return;
|
|
16463
|
+
}
|
|
16464
|
+
const clash = part === "rate" ? ["reads", "writes"].find((p) => seen.has(p)) : part === "reads" || part === "writes" ? seen.has("rate") ? part : void 0 : void 0;
|
|
16465
|
+
if (clash) {
|
|
16466
|
+
this.error(`A rate sets both reads and writes; give either a rate or reads and writes for '${id.value}'`, this.locOf(t, line));
|
|
16281
16467
|
return;
|
|
16282
16468
|
}
|
|
16283
16469
|
if (seen.has(part)) {
|
|
@@ -16288,7 +16474,7 @@ var Parser = class {
|
|
|
16288
16474
|
}
|
|
16289
16475
|
const idLoc = this.locOf(id, line);
|
|
16290
16476
|
if (seen.size === 0) {
|
|
16291
|
-
this.warning(`Capacity line for '${id.value}' overrides nothing; add
|
|
16477
|
+
this.warning(`Capacity line for '${id.value}' overrides nothing; add ${CAPACITY_PARTS}`, idLoc);
|
|
16292
16478
|
return;
|
|
16293
16479
|
}
|
|
16294
16480
|
const first = this.capacity.find((c) => c.node === id.value);
|
|
@@ -16298,7 +16484,14 @@ var Parser = class {
|
|
|
16298
16484
|
}
|
|
16299
16485
|
this.capacity.push(override);
|
|
16300
16486
|
this.deferred.push(() => {
|
|
16301
|
-
|
|
16487
|
+
const node = this.nodes.get(id.value);
|
|
16488
|
+
if (!node) {
|
|
16489
|
+
this.warning(`Unknown node '${id.value}' in capacity`, idLoc);
|
|
16490
|
+
return;
|
|
16491
|
+
}
|
|
16492
|
+
const storeOnly = ["shards", "consistency"].filter((p) => seen.has(p));
|
|
16493
|
+
if (storeOnly.length && !isDataStore(kindOf(node)))
|
|
16494
|
+
this.warning(`'${id.value}' is not a data store (${DATA_STORE_KINDS.join(", ")}); ${storeOnly.join(" and ")} only apply to data stores`, idLoc);
|
|
16302
16495
|
});
|
|
16303
16496
|
}
|
|
16304
16497
|
/** `entity Url [in db] ["description"] {` */
|
|
@@ -16483,10 +16676,28 @@ var Parser = class {
|
|
|
16483
16676
|
this.error(ASSERTION_HELP, this.locOf(first, line));
|
|
16484
16677
|
return;
|
|
16485
16678
|
}
|
|
16486
|
-
const
|
|
16679
|
+
const inUseCase = isWord(first, "in") && tokens[1]?.kind === "string" ? { name: tokens[1].value, loc: this.locOf(tokens[1], line) } : void 0;
|
|
16680
|
+
const target = selector(inUseCase ? 2 : 0);
|
|
16487
16681
|
if (!target) return;
|
|
16488
|
-
|
|
16489
|
-
|
|
16682
|
+
const word = tokens[target.next];
|
|
16683
|
+
if (isWord(word, "calls") || isWord(word, "never")) {
|
|
16684
|
+
const never = word.value === "never";
|
|
16685
|
+
if (never && !expectWords(target.next + 1, ["calls"], "any service never calls any storage")) return;
|
|
16686
|
+
const to = selector(target.next + (never ? 2 : 1));
|
|
16687
|
+
if (!to) return;
|
|
16688
|
+
const assertion3 = done(
|
|
16689
|
+
{ kind: "senderCalls", ...inUseCase ? { useCase: inUseCase.name } : {}, from: target.selector, to: to.selector, quantifier: never ? "never" : "some", loc },
|
|
16690
|
+
to.next
|
|
16691
|
+
);
|
|
16692
|
+
if (assertion3 && inUseCase) this.deferred.push(() => this.checkUseCase(inUseCase.name, inUseCase.loc));
|
|
16693
|
+
return assertion3;
|
|
16694
|
+
}
|
|
16695
|
+
if (inUseCase) {
|
|
16696
|
+
this.error(`Expected calls or never calls here, e.g. in "${inUseCase.name}" api calls db`, this.locOf(word ?? tokens[tokens.length - 1], line));
|
|
16697
|
+
return;
|
|
16698
|
+
}
|
|
16699
|
+
if (!isWord(word, "has")) {
|
|
16700
|
+
this.error(ASSERTION_HELP, this.locOf(word ?? first, line));
|
|
16490
16701
|
return;
|
|
16491
16702
|
}
|
|
16492
16703
|
const i2 = target.next + 1;
|
|
@@ -16527,6 +16738,10 @@ var Parser = class {
|
|
|
16527
16738
|
const then = selector(target.next + 1);
|
|
16528
16739
|
if (!then) return;
|
|
16529
16740
|
assertion2 = done({ kind: "before", useCase, ...withScenario, first: target.selector, then: then.selector, loc }, then.next);
|
|
16741
|
+
} else if (isWord(tokens[target.next], "after")) {
|
|
16742
|
+
const after2 = selector(target.next + 1);
|
|
16743
|
+
if (!after2) return;
|
|
16744
|
+
assertion2 = done({ kind: "after", useCase, ...withScenario, target: target.selector, after: after2.selector, loc }, after2.next);
|
|
16530
16745
|
} else {
|
|
16531
16746
|
assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "some", loc }, target.next);
|
|
16532
16747
|
}
|
|
@@ -16535,6 +16750,16 @@ var Parser = class {
|
|
|
16535
16750
|
const target = selector(i + 3);
|
|
16536
16751
|
if (!target) return;
|
|
16537
16752
|
assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "every", loc }, target.next);
|
|
16753
|
+
} else if (isWord(verb, "never") && isWord(tokens[i + 1], "waits")) {
|
|
16754
|
+
if (!expectWords(i + 2, ["for"], `"${useCase}" never waits for any queue`)) return;
|
|
16755
|
+
const target = selector(i + 3);
|
|
16756
|
+
if (!target) return;
|
|
16757
|
+
assertion2 = done({ kind: "neverWaits", useCase, ...withScenario, target: target.selector, loc }, target.next);
|
|
16758
|
+
} else if (isWord(verb, "starts")) {
|
|
16759
|
+
if (!wholeUseCase("starts at") || !expectWords(i + 1, ["at"], `"${useCase}" starts at any queue`)) return;
|
|
16760
|
+
const target = selector(i + 2);
|
|
16761
|
+
if (!target) return;
|
|
16762
|
+
assertion2 = done({ kind: "startsAt", useCase, target: target.selector, loc }, target.next);
|
|
16538
16763
|
} else if (isWord(verb, "never")) {
|
|
16539
16764
|
if (!expectWords(i + 1, ["calls"], `"${useCase}" never calls any database`)) return;
|
|
16540
16765
|
const target = selector(i + 2);
|
|
@@ -16576,8 +16801,23 @@ var Parser = class {
|
|
|
16576
16801
|
}
|
|
16577
16802
|
return assertion2;
|
|
16578
16803
|
}
|
|
16579
|
-
/** A node id, `[Tech]
|
|
16804
|
+
/** A selector starting at `tokens[i]`: one or more of node id, `[Tech]`, `any <kind>`, `any strong store`, joined by `or`. */
|
|
16580
16805
|
parseSelector(tokens, i, line) {
|
|
16806
|
+
const atoms = [];
|
|
16807
|
+
let next = i;
|
|
16808
|
+
for (; ; ) {
|
|
16809
|
+
const atom = this.parseSelectorAtom(tokens, next, line);
|
|
16810
|
+
if (!atom) return void 0;
|
|
16811
|
+
atoms.push(atom);
|
|
16812
|
+
next = atom.next;
|
|
16813
|
+
if (!isWord(tokens[next], "or")) break;
|
|
16814
|
+
next++;
|
|
16815
|
+
}
|
|
16816
|
+
const selector = atoms.length === 1 ? atoms[0].selector : { anyOf: atoms.map((a) => a.selector) };
|
|
16817
|
+
return { selector, next, atoms };
|
|
16818
|
+
}
|
|
16819
|
+
/** One node id, `[Tech]`, `any <kind>` or `any strong|eventual store` at `tokens[i]`. */
|
|
16820
|
+
parseSelectorAtom(tokens, i, line) {
|
|
16581
16821
|
const t = tokens[i];
|
|
16582
16822
|
if (t?.kind === "tech") {
|
|
16583
16823
|
const tech = techByName.get(t.value.toLowerCase())?.techStack ?? t.value;
|
|
@@ -16585,8 +16825,15 @@ var Parser = class {
|
|
|
16585
16825
|
}
|
|
16586
16826
|
if (isWord(t, "any")) {
|
|
16587
16827
|
const kind = tokens[i + 1];
|
|
16828
|
+
if (isWord(kind, "strong") || isWord(kind, "eventual")) {
|
|
16829
|
+
if (!isWord(tokens[i + 2], "store")) {
|
|
16830
|
+
this.error(`Expected 'store' here, e.g. any ${kind.value} store`, this.locOf(tokens[i + 2] ?? kind, line));
|
|
16831
|
+
return void 0;
|
|
16832
|
+
}
|
|
16833
|
+
return { selector: { consistency: kind.value }, next: i + 3, token: t, loc: this.locOf(t, line) };
|
|
16834
|
+
}
|
|
16588
16835
|
if (kind?.kind !== "ident" || !isKind(kind.value)) {
|
|
16589
|
-
this.error(`Expected a kind after any: ${KINDS.join(", ")}`, this.locOf(kind ?? t, line));
|
|
16836
|
+
this.error(`Expected a kind after any: ${KINDS.join(", ")}, or strong store / eventual store`, this.locOf(kind ?? t, line));
|
|
16590
16837
|
return void 0;
|
|
16591
16838
|
}
|
|
16592
16839
|
return { selector: { kind: kind.value }, next: i + 2, token: t, loc: this.locOf(t, line) };
|
|
@@ -16596,12 +16843,14 @@ var Parser = class {
|
|
|
16596
16843
|
return void 0;
|
|
16597
16844
|
}
|
|
16598
16845
|
/** Warns about a selector naming a tech stack that does not exist, and (once every file is read) a node that does not. */
|
|
16599
|
-
checkSelectorLater({
|
|
16600
|
-
|
|
16601
|
-
|
|
16602
|
-
|
|
16603
|
-
|
|
16604
|
-
|
|
16846
|
+
checkSelectorLater({ atoms }) {
|
|
16847
|
+
for (const { selector, token, loc } of atoms) {
|
|
16848
|
+
if ("tech" in selector && !techByName.has(token.value.toLowerCase())) this.warning(`Unknown tech stack '${token.value}'`, loc);
|
|
16849
|
+
if ("node" in selector) {
|
|
16850
|
+
this.deferred.push(() => {
|
|
16851
|
+
if (!this.nodes.has(selector.node)) this.warning(`Unknown node '${selector.node}'`, loc);
|
|
16852
|
+
});
|
|
16853
|
+
}
|
|
16605
16854
|
}
|
|
16606
16855
|
}
|
|
16607
16856
|
checkUseCase(name, loc) {
|
|
@@ -16615,10 +16864,10 @@ var Parser = class {
|
|
|
16615
16864
|
this.warning(`Use case '${useCase.name}' has no scenario '${name}'; its scenarios are ${names}`, loc);
|
|
16616
16865
|
}
|
|
16617
16866
|
/** The value of a quantity token in `unit`, or undefined after reporting what is wrong. `after` locates a missing token. */
|
|
16618
|
-
quantityOf(token, unit,
|
|
16867
|
+
quantityOf(token, unit, after2, line) {
|
|
16619
16868
|
const expected = UNIT_EXAMPLES[unit];
|
|
16620
16869
|
if (token?.kind !== "quantity" && token?.kind !== "number") {
|
|
16621
|
-
this.error(`Expected ${expected}${token ? `, not ${describe(token)}` : ""}`, this.locOf(token ??
|
|
16870
|
+
this.error(`Expected ${expected}${token ? `, not ${describe(token)}` : ""}`, this.locOf(token ?? after2, line));
|
|
16622
16871
|
return void 0;
|
|
16623
16872
|
}
|
|
16624
16873
|
const loc = this.locOf(token, line);
|
|
@@ -16721,7 +16970,10 @@ var Parser = class {
|
|
|
16721
16970
|
isParallel: step.parallelGroup !== void 0,
|
|
16722
16971
|
isConditional: false,
|
|
16723
16972
|
...step.arrow === "-x" ? { failed: true } : {},
|
|
16724
|
-
loc: step.loc
|
|
16973
|
+
loc: step.loc,
|
|
16974
|
+
...step.multiplier !== void 0 ? { multiplier: step.multiplier } : {},
|
|
16975
|
+
...step.sizeBytes !== void 0 ? { sizeBytes: step.sizeBytes } : {},
|
|
16976
|
+
access: accessOf(label.method, step.label)
|
|
16725
16977
|
});
|
|
16726
16978
|
}
|
|
16727
16979
|
return steps;
|
|
@@ -16887,6 +17139,16 @@ function parseStepLabel(input) {
|
|
|
16887
17139
|
const payload = splitPayload(input);
|
|
16888
17140
|
return { name: payload.text, format: payload.body ? payload.format : void 0, body: payload.body };
|
|
16889
17141
|
}
|
|
17142
|
+
var SIZE_UNITS = { B: 1, KB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12 };
|
|
17143
|
+
function parseSize(text) {
|
|
17144
|
+
const m = /^(\d+(?:\.\d+)?)([A-Za-z]*)$/.exec(text);
|
|
17145
|
+
if (!m) return { error: `Expected a payload size after ~, e.g. ~2MB, ~500KB or ~1.5GB${text ? `, not '~${text}'` : ""}` };
|
|
17146
|
+
const factor = SIZE_UNITS[m[2]];
|
|
17147
|
+
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` };
|
|
17148
|
+
const value = Math.round(Number(m[1]) * factor);
|
|
17149
|
+
if (value <= 0) return { error: `A payload size must be more than 0, not '~${text}'` };
|
|
17150
|
+
return { value };
|
|
17151
|
+
}
|
|
16890
17152
|
function inferProtocol(isAsync, method, target) {
|
|
16891
17153
|
if (isAsync || target?.type === "queue") return "MESSAGING";
|
|
16892
17154
|
if (!method) return "OTHER";
|
|
@@ -17093,8 +17355,8 @@ function alignConnections(run2) {
|
|
|
17093
17355
|
function commentOf(raw, tokens) {
|
|
17094
17356
|
const last = tokens[tokens.length - 1];
|
|
17095
17357
|
if (last.kind === "label") {
|
|
17096
|
-
const
|
|
17097
|
-
return
|
|
17358
|
+
const after2 = raw.slice(last.col);
|
|
17359
|
+
return after2.slice(stripTrailingComment(after2).length).trim();
|
|
17098
17360
|
}
|
|
17099
17361
|
return raw.slice(last.col - 1 + last.length).trim();
|
|
17100
17362
|
}
|
|
@@ -17262,6 +17524,104 @@ var RESERVED = new Set(
|
|
|
17262
17524
|
].map((w) => w.toLowerCase())
|
|
17263
17525
|
);
|
|
17264
17526
|
|
|
17527
|
+
// ../frontend/src/sim/access.ts
|
|
17528
|
+
function stepAccess(step) {
|
|
17529
|
+
return step.access ?? accessOf(step.httpMethod, step.stepName ?? "");
|
|
17530
|
+
}
|
|
17531
|
+
function accessIn(diagram) {
|
|
17532
|
+
const queues = new Set(diagram.nodes.filter((n) => kindOf(n) === "queue").map((n) => n.id));
|
|
17533
|
+
return (step) => queues.has(step.toServiceId) ? "write" : stepAccess(step);
|
|
17534
|
+
}
|
|
17535
|
+
|
|
17536
|
+
// ../frontend/src/sim/profiles.ts
|
|
17537
|
+
var NEUTRAL = { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false };
|
|
17538
|
+
var RELATIONAL = {
|
|
17539
|
+
rps: 2e4,
|
|
17540
|
+
readRps: 2e4,
|
|
17541
|
+
writeRps: 5e3,
|
|
17542
|
+
writeScaling: "shards",
|
|
17543
|
+
latencyMs: 5,
|
|
17544
|
+
availability: 0.9995,
|
|
17545
|
+
costUsd: 400,
|
|
17546
|
+
durable: true,
|
|
17547
|
+
consistency: "strong"
|
|
17548
|
+
};
|
|
17549
|
+
var KIND_PROFILES = {
|
|
17550
|
+
client: { ...NEUTRAL, bandwidthMBps: 10 },
|
|
17551
|
+
edge: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
|
|
17552
|
+
cdn: { rps: 2e5, latencyMs: 5, availability: 0.9999, costUsd: 100, durable: false, egressUsdPerGb: 0.02 },
|
|
17553
|
+
loadbalancer: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
|
|
17554
|
+
gateway: { rps: 1e4, latencyMs: 10, availability: 0.9995, costUsd: 100, durable: false },
|
|
17555
|
+
// Name resolution happens before the request and is cached: off the request path.
|
|
17556
|
+
dns: { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false },
|
|
17557
|
+
service: { rps: 2e3, latencyMs: 10, availability: 0.995, costUsd: 100, durable: false },
|
|
17558
|
+
function: { rps: 1e4, latencyMs: 25, availability: 0.9995, costUsd: 200, durable: false },
|
|
17559
|
+
cache: { rps: 1e5, latencyMs: 1, availability: 0.999, costUsd: 150, durable: false, consistency: "eventual" },
|
|
17560
|
+
database: RELATIONAL,
|
|
17561
|
+
search: { rps: 3e3, latencyMs: 15, availability: 0.999, costUsd: 400, durable: true, consistency: "eventual" },
|
|
17562
|
+
analytics: { rps: 200, latencyMs: 500, availability: 0.999, costUsd: 300, durable: true, consistency: "eventual" },
|
|
17563
|
+
queue: { rps: 5e4, latencyMs: 5, availability: 0.9999, costUsd: 200, durable: true, consistency: "strong" },
|
|
17564
|
+
storage: { rps: 5e3, latencyMs: 30, availability: 0.9999, costUsd: 50, durable: true, consistency: "eventual", egressUsdPerGb: 0.09 },
|
|
17565
|
+
external: { rps: 1e3, latencyMs: 200, availability: 0.999, costUsd: 0, durable: false },
|
|
17566
|
+
other: NEUTRAL
|
|
17567
|
+
};
|
|
17568
|
+
var NOSQL = { rps: 2e4, latencyMs: 5, availability: 0.9999, costUsd: 500, durable: true, consistency: "eventual" };
|
|
17569
|
+
var NOSQL_STRONG = { ...NOSQL, consistency: "strong" };
|
|
17570
|
+
var TECH_PROFILES = {
|
|
17571
|
+
DynamoDB: NOSQL,
|
|
17572
|
+
"AWS DynamoDB": NOSQL,
|
|
17573
|
+
Cassandra: NOSQL,
|
|
17574
|
+
CouchDB: NOSQL,
|
|
17575
|
+
"Azure Cosmos DB": NOSQL,
|
|
17576
|
+
MongoDB: NOSQL_STRONG,
|
|
17577
|
+
"GCP Bigtable": NOSQL_STRONG,
|
|
17578
|
+
"GCP Firestore": NOSQL_STRONG,
|
|
17579
|
+
"GCP Spanner": NOSQL_STRONG
|
|
17580
|
+
};
|
|
17581
|
+
var BANDWIDTH = {
|
|
17582
|
+
client: 10,
|
|
17583
|
+
edge: 1e3,
|
|
17584
|
+
cdn: 1e3,
|
|
17585
|
+
loadbalancer: 1e3,
|
|
17586
|
+
gateway: 1e3,
|
|
17587
|
+
dns: 1e3,
|
|
17588
|
+
service: 200
|
|
17589
|
+
};
|
|
17590
|
+
var DEFAULT_BANDWIDTH_MBPS = 100;
|
|
17591
|
+
function profileOf(node, override) {
|
|
17592
|
+
const kind = kindOf(node);
|
|
17593
|
+
const base = TECH_PROFILES[node.techStack] ?? KIND_PROFILES[kind];
|
|
17594
|
+
const baseRead = base.readRps ?? base.rps;
|
|
17595
|
+
const baseWrite = base.writeRps ?? base.rps;
|
|
17596
|
+
const readRps = override?.readRps ?? override?.rps ?? baseRead;
|
|
17597
|
+
const writeRps = override?.writeRps ?? override?.rps ?? baseWrite;
|
|
17598
|
+
const consistency = override?.consistency ?? base.consistency;
|
|
17599
|
+
return {
|
|
17600
|
+
kind,
|
|
17601
|
+
rps: readRps,
|
|
17602
|
+
readRps,
|
|
17603
|
+
writeRps,
|
|
17604
|
+
writeScaling: base.writeScaling ?? "replicas",
|
|
17605
|
+
shards: Math.max(1, Math.floor(override?.shards ?? 1)),
|
|
17606
|
+
latencyMs: override?.latencyMs ?? base.latencyMs,
|
|
17607
|
+
availability: override?.availability !== void 0 ? override.availability / 100 : base.availability,
|
|
17608
|
+
costUsd: override?.costUsd ?? base.costUsd,
|
|
17609
|
+
durable: override?.durable ?? base.durable,
|
|
17610
|
+
...consistency && isDataStore(kind) ? { consistency } : {},
|
|
17611
|
+
bandwidthMBps: override?.bandwidthMBps ?? base.bandwidthMBps ?? BANDWIDTH[kind] ?? DEFAULT_BANDWIDTH_MBPS,
|
|
17612
|
+
egressUsdPerGb: override?.egressUsdPerGb ?? base.egressUsdPerGb ?? 0
|
|
17613
|
+
};
|
|
17614
|
+
}
|
|
17615
|
+
function capacityOf(p, replicas) {
|
|
17616
|
+
return {
|
|
17617
|
+
readRps: p.readRps * replicas * p.shards,
|
|
17618
|
+
writeRps: p.writeRps * (p.writeScaling === "shards" ? 1 : replicas) * p.shards
|
|
17619
|
+
};
|
|
17620
|
+
}
|
|
17621
|
+
function combinedUtilization(p, readUtilization, writeUtilization) {
|
|
17622
|
+
return p.writeScaling === "shards" ? Math.max(readUtilization, writeUtilization) : readUtilization + writeUtilization;
|
|
17623
|
+
}
|
|
17624
|
+
|
|
17265
17625
|
// ../frontend/src/sim/flow.ts
|
|
17266
17626
|
function findUseCase(diagram, name) {
|
|
17267
17627
|
const lower = name.toLowerCase();
|
|
@@ -17326,9 +17686,9 @@ function requestOrder(useCase, scenario) {
|
|
|
17326
17686
|
}
|
|
17327
17687
|
return { requests, beforeResponse: beforeResponse < 0 ? requests.length : beforeResponse };
|
|
17328
17688
|
}
|
|
17329
|
-
function writesBeforeResponse(useCase, scenario, accept) {
|
|
17689
|
+
function writesBeforeResponse(useCase, scenario, accept, isWrite = (step) => stepAccess(step) === "write") {
|
|
17330
17690
|
const { requests, beforeResponse } = requestOrder(useCase, scenario);
|
|
17331
|
-
return requests.slice(0, beforeResponse).some((m) => !m.async && !m.failed && accept(m.to));
|
|
17691
|
+
return requests.slice(0, beforeResponse).some((m) => !m.async && !m.failed && accept(m.to) && isWrite(m.step));
|
|
17332
17692
|
}
|
|
17333
17693
|
function callsOk(scenario, nodeId) {
|
|
17334
17694
|
return scenario.steps.some((s) => s.toServiceId === nodeId && !s.failed);
|
|
@@ -17337,23 +17697,34 @@ function needs(useCase, nodeId) {
|
|
|
17337
17697
|
return useCase.scenarios.some((s) => s.outcome === "success" && callsOk(s, nodeId));
|
|
17338
17698
|
}
|
|
17339
17699
|
function fallbacksFor(useCase, nodeId) {
|
|
17340
|
-
return useCase.scenarios.filter(
|
|
17341
|
-
(s
|
|
17342
|
-
|
|
17700
|
+
return useCase.scenarios.filter((s) => {
|
|
17701
|
+
if (s.outcome !== "success") return false;
|
|
17702
|
+
const { requests, beforeResponse } = requestOrder(useCase, s);
|
|
17703
|
+
const early = requests.slice(0, beforeResponse).filter((m) => m.to === nodeId);
|
|
17704
|
+
const window = early.some((m) => m.failed) ? early : requests.filter((m) => m.to === nodeId);
|
|
17705
|
+
return window.some((m) => m.failed) && !window.some((m) => !m.failed && !m.async);
|
|
17706
|
+
});
|
|
17343
17707
|
}
|
|
17344
17708
|
function selectorText(selector) {
|
|
17345
17709
|
if ("node" in selector) return selector.node;
|
|
17346
17710
|
if ("tech" in selector) return `[${selector.tech}]`;
|
|
17711
|
+
if ("anyOf" in selector) return selector.anyOf.map(selectorText).join(" or ");
|
|
17712
|
+
if ("consistency" in selector) return `any ${selector.consistency} store`;
|
|
17347
17713
|
return `any ${selector.kind}`;
|
|
17348
17714
|
}
|
|
17349
17715
|
function selectNodes(diagram, selector) {
|
|
17350
|
-
return diagram.nodes.filter((n) => n.kind === "component" && matches(n, selector));
|
|
17716
|
+
return diagram.nodes.filter((n) => n.kind === "component" && matches(n, selector, diagram));
|
|
17351
17717
|
}
|
|
17352
|
-
function matches(node, selector) {
|
|
17718
|
+
function matches(node, selector, diagram) {
|
|
17353
17719
|
if (node.kind !== "component") return false;
|
|
17354
17720
|
if ("node" in selector) return node.id === selector.node;
|
|
17355
17721
|
if ("tech" in selector) return node.techStack.toLowerCase() === selector.tech.toLowerCase();
|
|
17356
|
-
|
|
17722
|
+
if ("anyOf" in selector) return selector.anyOf.some((s) => matches(node, s, diagram));
|
|
17723
|
+
if ("consistency" in selector) {
|
|
17724
|
+
const override = diagram?.capacity?.find((o) => o.node === node.id);
|
|
17725
|
+
return profileOf(node, override).consistency === selector.consistency;
|
|
17726
|
+
}
|
|
17727
|
+
return kindMatches(kindOf(node), selector.kind);
|
|
17357
17728
|
}
|
|
17358
17729
|
function techOf(node) {
|
|
17359
17730
|
return node && !node.implicit ? node.techStack : void 0;
|
|
@@ -17387,51 +17758,13 @@ function formatUsd(usd) {
|
|
|
17387
17758
|
return `$${Math.round(usd).toLocaleString("en-US")}/month`;
|
|
17388
17759
|
}
|
|
17389
17760
|
|
|
17390
|
-
// ../frontend/src/sim/profiles.ts
|
|
17391
|
-
var NEUTRAL = { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false };
|
|
17392
|
-
var KIND_PROFILES = {
|
|
17393
|
-
client: NEUTRAL,
|
|
17394
|
-
edge: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
|
|
17395
|
-
service: { rps: 2e3, latencyMs: 10, availability: 0.995, costUsd: 100, durable: false },
|
|
17396
|
-
function: { rps: 1e4, latencyMs: 25, availability: 0.9995, costUsd: 200, durable: false },
|
|
17397
|
-
cache: { rps: 1e5, latencyMs: 1, availability: 0.999, costUsd: 150, durable: false },
|
|
17398
|
-
database: { rps: 5e3, latencyMs: 5, availability: 0.9995, costUsd: 400, durable: true },
|
|
17399
|
-
search: { rps: 3e3, latencyMs: 15, availability: 0.999, costUsd: 400, durable: true },
|
|
17400
|
-
analytics: { rps: 200, latencyMs: 500, availability: 0.999, costUsd: 300, durable: true },
|
|
17401
|
-
queue: { rps: 5e4, latencyMs: 5, availability: 0.9999, costUsd: 200, durable: true },
|
|
17402
|
-
storage: { rps: 5e3, latencyMs: 30, availability: 0.9999, costUsd: 50, durable: true },
|
|
17403
|
-
external: { rps: 1e3, latencyMs: 200, availability: 0.999, costUsd: 0, durable: false },
|
|
17404
|
-
other: NEUTRAL
|
|
17405
|
-
};
|
|
17406
|
-
var NOSQL = { rps: 2e4, latencyMs: 5, availability: 0.9999, costUsd: 500, durable: true };
|
|
17407
|
-
var TECH_PROFILES = {
|
|
17408
|
-
DynamoDB: NOSQL,
|
|
17409
|
-
"AWS DynamoDB": NOSQL,
|
|
17410
|
-
Cassandra: NOSQL,
|
|
17411
|
-
MongoDB: NOSQL,
|
|
17412
|
-
CouchDB: NOSQL,
|
|
17413
|
-
"Azure Cosmos DB": NOSQL,
|
|
17414
|
-
"GCP Bigtable": NOSQL,
|
|
17415
|
-
"GCP Firestore": NOSQL,
|
|
17416
|
-
"GCP Spanner": NOSQL
|
|
17417
|
-
};
|
|
17418
|
-
function profileOf(node, override) {
|
|
17419
|
-
const kind = kindOf(node);
|
|
17420
|
-
const base = TECH_PROFILES[node.techStack] ?? KIND_PROFILES[kind];
|
|
17421
|
-
return {
|
|
17422
|
-
kind,
|
|
17423
|
-
rps: override?.rps ?? base.rps,
|
|
17424
|
-
latencyMs: override?.latencyMs ?? base.latencyMs,
|
|
17425
|
-
availability: override?.availability !== void 0 ? override.availability / 100 : base.availability,
|
|
17426
|
-
costUsd: override?.costUsd ?? base.costUsd,
|
|
17427
|
-
durable: override?.durable ?? base.durable
|
|
17428
|
-
};
|
|
17429
|
-
}
|
|
17430
|
-
|
|
17431
17761
|
// ../frontend/src/sim/analyze.ts
|
|
17432
17762
|
var DEFAULT_TIMEOUT_MS = 1e3;
|
|
17433
17763
|
var HOT = 0.7;
|
|
17434
17764
|
var QUEUE_CAP = 0.95;
|
|
17765
|
+
var SECONDS_PER_MONTH = 2592e3;
|
|
17766
|
+
var BYTES_PER_MB = 1e6;
|
|
17767
|
+
var BYTES_PER_GB = 1e9;
|
|
17435
17768
|
var PERCENTILE_FACTORS = { 50: 1, 90: 1.6, 95: 2, 99: 3, 99.9: 5 };
|
|
17436
17769
|
var PERCENTILE_KEYS = { 50: "p50", 90: "p90", 95: "p95", 99: "p99", 99.9: "p999" };
|
|
17437
17770
|
var PERCENTILES2 = Object.keys(PERCENTILE_KEYS).map(Number);
|
|
@@ -17467,20 +17800,73 @@ function resolveTraffic(diagram, warnings = []) {
|
|
|
17467
17800
|
}
|
|
17468
17801
|
return result;
|
|
17469
17802
|
}
|
|
17470
|
-
|
|
17471
|
-
|
|
17803
|
+
var multiplierOf = (step) => step.multiplier !== void 0 && step.multiplier > 0 ? step.multiplier : 1;
|
|
17804
|
+
function forEachStepRate(diagram, traffic, visit) {
|
|
17472
17805
|
for (const u of diagram.useCases) {
|
|
17473
17806
|
const { rps, shares } = traffic.get(u.id) ?? { rps: 0, shares: [] };
|
|
17474
17807
|
u.scenarios.forEach((s, i) => {
|
|
17475
17808
|
const rate = rps * (shares[i] ?? 0);
|
|
17476
|
-
if (rate > 0) for (const step of s.steps)
|
|
17809
|
+
if (rate > 0) for (const step of s.steps) visit(step, rate);
|
|
17477
17810
|
});
|
|
17478
17811
|
}
|
|
17812
|
+
}
|
|
17813
|
+
function computeLoad(diagram, traffic) {
|
|
17814
|
+
const load = /* @__PURE__ */ new Map();
|
|
17815
|
+
const access = accessIn(diagram);
|
|
17816
|
+
forEachStepRate(diagram, traffic, (step, rate) => {
|
|
17817
|
+
const entry = load.get(step.toServiceId) ?? { read: 0, write: 0 };
|
|
17818
|
+
entry[access(step)] += rate * multiplierOf(step);
|
|
17819
|
+
load.set(step.toServiceId, entry);
|
|
17820
|
+
});
|
|
17479
17821
|
return load;
|
|
17480
17822
|
}
|
|
17823
|
+
var egressSource = (step, access) => access === "read" ? step.toServiceId : step.fromServiceId;
|
|
17824
|
+
function computeEgressGb(diagram, traffic) {
|
|
17825
|
+
const gb = /* @__PURE__ */ new Map();
|
|
17826
|
+
const access = accessIn(diagram);
|
|
17827
|
+
forEachStepRate(diagram, traffic, (step, rate) => {
|
|
17828
|
+
if (!step.sizeBytes || step.sizeBytes <= 0) return;
|
|
17829
|
+
const source2 = egressSource(step, access(step));
|
|
17830
|
+
gb.set(source2, (gb.get(source2) ?? 0) + rate * multiplierOf(step) * step.sizeBytes * SECONDS_PER_MONTH / BYTES_PER_GB);
|
|
17831
|
+
});
|
|
17832
|
+
return gb;
|
|
17833
|
+
}
|
|
17834
|
+
function transferMs(step, bandwidthOf) {
|
|
17835
|
+
if (!step.sizeBytes || step.sizeBytes <= 0) return 0;
|
|
17836
|
+
const mbps = Math.min(bandwidthOf(step.fromServiceId), bandwidthOf(step.toServiceId));
|
|
17837
|
+
return mbps > 0 && Number.isFinite(mbps) ? step.sizeBytes / BYTES_PER_MB / mbps * 1e3 : 0;
|
|
17838
|
+
}
|
|
17481
17839
|
var utilizationOf = (load, capacity) => Number.isFinite(capacity) && capacity > 0 ? load / capacity : 0;
|
|
17482
17840
|
var hopLatency = (baseMs, utilization) => baseMs / (1 - Math.min(utilization, QUEUE_CAP));
|
|
17483
17841
|
var replicatedAvailability = (a, replicas) => 1 - (1 - a) ** replicas;
|
|
17842
|
+
function usageOf(p, replicas, readLoadRps, writeLoadRps) {
|
|
17843
|
+
const { readRps: readCapacityRps, writeRps: writeCapacityRps } = capacityOf(p, replicas);
|
|
17844
|
+
const readUtilization = utilizationOf(readLoadRps, readCapacityRps);
|
|
17845
|
+
const writeUtilization = utilizationOf(writeLoadRps, writeCapacityRps);
|
|
17846
|
+
const utilization = combinedUtilization(p, readUtilization, writeUtilization);
|
|
17847
|
+
const loadRps = readLoadRps + writeLoadRps;
|
|
17848
|
+
return {
|
|
17849
|
+
loadRps,
|
|
17850
|
+
capacityRps: readCapacityRps === writeCapacityRps || utilization === 0 ? readCapacityRps : loadRps / utilization,
|
|
17851
|
+
utilization,
|
|
17852
|
+
saturated: utilization >= 1,
|
|
17853
|
+
readLoadRps,
|
|
17854
|
+
writeLoadRps,
|
|
17855
|
+
readCapacityRps,
|
|
17856
|
+
writeCapacityRps,
|
|
17857
|
+
readUtilization,
|
|
17858
|
+
writeUtilization,
|
|
17859
|
+
writeScaling: p.writeScaling
|
|
17860
|
+
};
|
|
17861
|
+
}
|
|
17862
|
+
function usageText(n) {
|
|
17863
|
+
const part = (load, capacity, rho) => `${formatRps(load)} of ${formatRps(capacity)}, ${formatPercent(rho)}`;
|
|
17864
|
+
if (n.writeScaling === "shards" && n.writeLoadRps > 0) {
|
|
17865
|
+
return `reads ${part(n.readLoadRps, n.readCapacityRps, n.readUtilization)}; writes ${part(n.writeLoadRps, n.writeCapacityRps, n.writeUtilization)}`;
|
|
17866
|
+
}
|
|
17867
|
+
return part(n.loadRps, n.capacityRps, n.utilization);
|
|
17868
|
+
}
|
|
17869
|
+
var writeBound = (n) => n.writeScaling === "shards" && n.writeUtilization >= n.readUtilization && n.writeLoadRps > 0;
|
|
17484
17870
|
function useCasePercentile(scenarios, q) {
|
|
17485
17871
|
const tail = 1 - q / 100 - 1e-9;
|
|
17486
17872
|
const heavy = scenarios.filter((s) => s.share >= tail && s.share > 0);
|
|
@@ -17499,49 +17885,57 @@ function mainScenarioIndex(shares) {
|
|
|
17499
17885
|
});
|
|
17500
17886
|
return best;
|
|
17501
17887
|
}
|
|
17888
|
+
function profilesOf(diagram, warnings = []) {
|
|
17889
|
+
const nodes = components(diagram);
|
|
17890
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
17891
|
+
const overrides = new Map((diagram.capacity ?? []).map((o) => [o.node, o]));
|
|
17892
|
+
for (const o of diagram.capacity ?? []) if (!ids.has(o.node)) warnings.push(`Capacity for unknown node '${o.node}' is ignored`);
|
|
17893
|
+
return new Map(nodes.map((n) => [n.id, profileOf(n, overrides.get(n.id))]));
|
|
17894
|
+
}
|
|
17502
17895
|
function analyze(diagram, options = {}) {
|
|
17503
17896
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
17504
17897
|
const warnings = [];
|
|
17505
17898
|
const nodes = components(diagram);
|
|
17506
|
-
const
|
|
17507
|
-
const overrides = new Map((diagram.capacity ?? []).map((o) => [o.node, o]));
|
|
17508
|
-
for (const o of diagram.capacity ?? []) if (!byId.has(o.node)) warnings.push(`Capacity for unknown node '${o.node}' is ignored`);
|
|
17509
|
-
const profiles = new Map(nodes.map((n) => [n.id, profileOf(n, overrides.get(n.id))]));
|
|
17899
|
+
const profiles = profilesOf(diagram, warnings);
|
|
17510
17900
|
const traffic = resolveTraffic(diagram, warnings);
|
|
17511
17901
|
const load = computeLoad(diagram, traffic);
|
|
17902
|
+
const egress = computeEgressGb(diagram, traffic);
|
|
17512
17903
|
const nodeResults = nodes.map((n) => {
|
|
17513
17904
|
const p = profiles.get(n.id);
|
|
17514
17905
|
const replicas = replicasOf(n);
|
|
17515
|
-
const
|
|
17516
|
-
const
|
|
17517
|
-
const
|
|
17906
|
+
const { read, write } = load.get(n.id) ?? { read: 0, write: 0 };
|
|
17907
|
+
const usage = usageOf(p, replicas, read, write);
|
|
17908
|
+
const egressGbPerMonth = egress.get(n.id) ?? 0;
|
|
17909
|
+
const egressUsd = egressGbPerMonth * p.egressUsdPerGb;
|
|
17518
17910
|
return {
|
|
17519
17911
|
id: n.id,
|
|
17520
17912
|
kind: p.kind,
|
|
17521
17913
|
replicas,
|
|
17522
|
-
|
|
17523
|
-
|
|
17524
|
-
|
|
17525
|
-
|
|
17526
|
-
latencyMs: hopLatency(p.latencyMs, utilization),
|
|
17914
|
+
shards: p.shards,
|
|
17915
|
+
...usage,
|
|
17916
|
+
...p.consistency ? { consistency: p.consistency } : {},
|
|
17917
|
+
latencyMs: hopLatency(p.latencyMs, usage.utilization),
|
|
17527
17918
|
availability: replicatedAvailability(p.availability, replicas),
|
|
17528
|
-
costUsd: replicas * p.costUsd,
|
|
17919
|
+
costUsd: replicas * p.shards * p.costUsd + egressUsd,
|
|
17920
|
+
egressGbPerMonth,
|
|
17921
|
+
egressUsd,
|
|
17529
17922
|
durable: p.durable
|
|
17530
17923
|
};
|
|
17531
17924
|
});
|
|
17532
17925
|
const nodeById = new Map(nodeResults.map((n) => [n.id, n]));
|
|
17533
17926
|
const hopMs = (id) => nodeById.get(id)?.latencyMs ?? 0;
|
|
17534
17927
|
const availabilityOf = (id) => nodeById.get(id)?.availability ?? 1;
|
|
17928
|
+
const bandwidthOf = (id) => profiles.get(id)?.bandwidthMBps ?? Infinity;
|
|
17535
17929
|
for (const n of nodeResults) {
|
|
17536
|
-
const usage =
|
|
17537
|
-
if (n.saturated) warnings.push(`'${n.id}' is saturated: ${usage}. Add replicas or take load off it.`);
|
|
17930
|
+
const usage = usageText(n);
|
|
17931
|
+
if (n.saturated) warnings.push(`'${n.id}' is saturated: ${usage}. ${writeBound(n) ? "Add shards for writes" : "Add replicas"} or take load off it.`);
|
|
17538
17932
|
else if (n.utilization > HOT) warnings.push(`'${n.id}' runs hot: ${usage}.`);
|
|
17539
17933
|
}
|
|
17540
17934
|
const useCases = diagram.useCases.map((u) => {
|
|
17541
17935
|
const { rps, shares } = traffic.get(u.id);
|
|
17542
17936
|
const scenarios = u.scenarios.map((s, i) => {
|
|
17543
17937
|
const meanMs = criticalPath(u, s).reduce(
|
|
17544
|
-
(sum, part) => sum + Math.max(...part.map((hop) => hop.failed ? timeoutMs : hopMs(hop.target))),
|
|
17938
|
+
(sum, part) => sum + Math.max(...part.map((hop) => hop.failed ? timeoutMs : hopMs(hop.target) + transferMs(hop.step, bandwidthOf))),
|
|
17545
17939
|
0
|
|
17546
17940
|
);
|
|
17547
17941
|
return { id: s.id, name: s.name, share: shares[i] ?? 0, meanMs, percentiles: percentilesOf(meanMs) };
|
|
@@ -17556,6 +17950,7 @@ function analyze(diagram, options = {}) {
|
|
|
17556
17950
|
nodes: nodeResults,
|
|
17557
17951
|
useCases,
|
|
17558
17952
|
totalCostUsd: nodeResults.reduce((sum, n) => sum + n.costUsd, 0),
|
|
17953
|
+
totalEgressUsd: nodeResults.reduce((sum, n) => sum + n.egressUsd, 0),
|
|
17559
17954
|
singlePointsOfFailure: singlePointsOfFailure(diagram),
|
|
17560
17955
|
warnings
|
|
17561
17956
|
};
|
|
@@ -17580,7 +17975,7 @@ function useCaseAvailability(useCase, shares, availabilityOf) {
|
|
|
17580
17975
|
}
|
|
17581
17976
|
return result;
|
|
17582
17977
|
}
|
|
17583
|
-
var DEPLOYED = (kind) => kind !== "client" && kind !== "other" && kind !== "external";
|
|
17978
|
+
var DEPLOYED = (kind) => kind !== "client" && kind !== "other" && kind !== "external" && kind !== "dns";
|
|
17584
17979
|
function singlePointsOfFailure(diagram) {
|
|
17585
17980
|
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);
|
|
17586
17981
|
}
|
|
@@ -17589,20 +17984,20 @@ function singlePointsOfFailure(diagram) {
|
|
|
17589
17984
|
var pass = (message) => ({ passed: true, message });
|
|
17590
17985
|
var fail = (message, hint) => ({ passed: false, message, hint });
|
|
17591
17986
|
function runTests(diagram, analysis = analyze(diagram)) {
|
|
17987
|
+
const access = accessIn(diagram);
|
|
17592
17988
|
const run2 = {
|
|
17593
17989
|
diagram,
|
|
17594
17990
|
analysis,
|
|
17595
17991
|
nodes: new Map(analysis.nodes.map((n) => [n.id, n])),
|
|
17596
17992
|
useCases: new Map(analysis.useCases.map((u) => [u.id, u])),
|
|
17597
|
-
shares: new Map([...resolveTraffic(diagram)].map(([id, t]) => [id, t.shares]))
|
|
17993
|
+
shares: new Map([...resolveTraffic(diagram)].map(([id, t]) => [id, t.shares])),
|
|
17994
|
+
profiles: profilesOf(diagram),
|
|
17995
|
+
isWrite: (step) => access(step) === "write"
|
|
17598
17996
|
};
|
|
17599
|
-
const results = (diagram.requirements ?? []).map((r, i) =>
|
|
17600
|
-
|
|
17601
|
-
name: requirementName(r),
|
|
17602
|
-
|
|
17603
|
-
...requirement(run2, r),
|
|
17604
|
-
loc: r.loc
|
|
17605
|
-
}));
|
|
17997
|
+
const results = (diagram.requirements ?? []).map((r, i) => {
|
|
17998
|
+
const { passed, message, hint } = requirement(run2, r);
|
|
17999
|
+
return { id: `req:${i + 1}`, name: requirementName(r), category: CATEGORY[r.kind], passed, message, hint, loc: r.loc };
|
|
18000
|
+
});
|
|
17606
18001
|
const names = /* @__PURE__ */ new Map();
|
|
17607
18002
|
for (const test of diagram.tests ?? []) {
|
|
17608
18003
|
const n = (names.get(test.name) ?? 0) + 1;
|
|
@@ -17619,10 +18014,16 @@ var CATEGORY = {
|
|
|
17619
18014
|
cost: "cost"
|
|
17620
18015
|
};
|
|
17621
18016
|
var percentileName = (q) => PERCENTILE_KEYS[q];
|
|
18017
|
+
var lineOf = (loc) => `${loc.file ? `${loc.file}:` : "line "}${loc.line}`;
|
|
18018
|
+
function stepText(step) {
|
|
18019
|
+
const arrow = step.failed ? "-x" : step.executionType === "SYNC_REQUEST_RESPONSE" ? "->" : "->>";
|
|
18020
|
+
const label = requestLabel(step);
|
|
18021
|
+
return `${step.fromServiceId} ${arrow} ${step.toServiceId}${label ? ` : ${label}` : ""} at ${lineOf(step.loc)}`;
|
|
18022
|
+
}
|
|
17622
18023
|
function requirementName(r) {
|
|
17623
18024
|
switch (r.kind) {
|
|
17624
18025
|
case "latency":
|
|
17625
|
-
return `${percentileName(r.percentile)} of ${r.useCase ?? "every use case"} < ${formatMs(r.maxMs)}`;
|
|
18026
|
+
return `${percentileName(r.percentile)} of ${r.useCase ?? "every use case"}${r.scenario !== void 0 ? ` scenario ${r.scenario}` : ""} < ${formatMs(r.maxMs)}`;
|
|
17626
18027
|
case "availability":
|
|
17627
18028
|
return `availability of ${r.useCase ?? "every use case"} \u2265 ${r.minPercent}%`;
|
|
17628
18029
|
case "durable":
|
|
@@ -17636,6 +18037,7 @@ function requirementName(r) {
|
|
|
17636
18037
|
function requirement(run2, r) {
|
|
17637
18038
|
switch (r.kind) {
|
|
17638
18039
|
case "latency":
|
|
18040
|
+
if (r.useCase !== void 0 && r.scenario !== void 0) return scenarioLatency(run2, r.useCase, r.scenario, r.percentile, r.maxMs);
|
|
17639
18041
|
return overUseCases(run2, r.useCase, (u) => latency(run2, u, r.percentile, r.maxMs), `(limit ${formatMs(r.maxMs)})`);
|
|
17640
18042
|
case "availability":
|
|
17641
18043
|
return overUseCases(run2, r.useCase, (u) => availability(run2, u, r.minPercent), `(limit ${r.minPercent}%)`, true);
|
|
@@ -17664,43 +18066,65 @@ function overUseCases(run2, name, check, limit, anyWithoutTraffic = false) {
|
|
|
17664
18066
|
}
|
|
17665
18067
|
function unknownUseCase(run2, name) {
|
|
17666
18068
|
const known = run2.diagram.useCases.map((u) => `"${u.name}"`);
|
|
17667
|
-
return fail(`No use case named "${name}"`, known.length ? `Use one of ${known.join(", ")}` : 'Add a usecase "\u2026" { \u2026 } block');
|
|
18069
|
+
return { ...fail(`No use case named "${name}"`, known.length ? `Use one of ${known.join(", ")}` : 'Add a usecase "\u2026" { \u2026 } block'), missing: `use case ${name}` };
|
|
18070
|
+
}
|
|
18071
|
+
function saturatedOn(run2, ids, value, maxMs) {
|
|
18072
|
+
const n = [...new Set(ids)].map((id) => run2.nodes.get(id)).find((x) => !!x?.saturated);
|
|
18073
|
+
if (!n) return void 0;
|
|
18074
|
+
const v = value(n);
|
|
18075
|
+
return { ...fail(`${v} (limit ${formatMs(maxMs)})`, saturationHint(run2, n)), value: v };
|
|
17668
18076
|
}
|
|
17669
18077
|
function latency(run2, u, q, maxMs) {
|
|
17670
18078
|
const result = run2.useCases.get(u.id);
|
|
17671
18079
|
const key = percentileName(q);
|
|
17672
18080
|
const shares = run2.shares.get(u.id) ?? [];
|
|
17673
|
-
const touched =
|
|
17674
|
-
const saturated =
|
|
17675
|
-
if (saturated
|
|
17676
|
-
const n = saturated[0];
|
|
17677
|
-
const usage = `${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)}, ${formatPercent(n.utilization)}`;
|
|
17678
|
-
const value2 = `${key} of ${u.name}: ${n.id} is saturated (${usage})`;
|
|
17679
|
-
return { ...fail(`${value2} (limit ${formatMs(maxMs)})`, saturationHint(run2, n)), value: value2 };
|
|
17680
|
-
}
|
|
18081
|
+
const touched = u.scenarios.flatMap((s, i) => (shares[i] ?? 0) > 0 ? s.steps.map((step) => step.toServiceId) : []);
|
|
18082
|
+
const saturated = saturatedOn(run2, touched, (n) => `${key} of ${u.name}: ${n.id} is saturated (${usageText(n)})`, maxMs);
|
|
18083
|
+
if (saturated) return saturated;
|
|
17681
18084
|
const ms = result.percentiles[key];
|
|
17682
18085
|
const value = `${key} of ${u.name} is ${formatMs(ms)}${result.rps > 0 ? "" : " with no traffic"}`;
|
|
17683
18086
|
const message = `${value} (limit ${formatMs(maxMs)})`;
|
|
17684
|
-
|
|
18087
|
+
if (ms < maxMs) return { ...pass(message), value };
|
|
18088
|
+
const index = Math.max(0, result.scenarios.findIndex((s) => s.percentiles[key] === ms));
|
|
18089
|
+
const lead = u.scenarios.length > 1 ? `The "${u.scenarios[index].name}" path (${formatPercent(result.scenarios[index].share)} of traffic) sets ${key}. ` : "";
|
|
18090
|
+
return { ...fail(message, lead + latencyHint(run2, u, u.scenarios[index])), value };
|
|
18091
|
+
}
|
|
18092
|
+
function scenarioLatency(run2, useCase, scenario, q, maxMs) {
|
|
18093
|
+
const sc = scope(run2, useCase, scenario);
|
|
18094
|
+
if (isCheck(sc)) return sc;
|
|
18095
|
+
const s = sc.scenarios[0];
|
|
18096
|
+
const key = percentileName(q);
|
|
18097
|
+
const what = `${key} of "${sc.u.name}" scenario "${s.name}"`;
|
|
18098
|
+
const saturated = saturatedOn(run2, s.steps.map((step) => step.toServiceId), (n) => `${what}: ${n.id} is saturated (${usageText(n)})`, maxMs);
|
|
18099
|
+
if (saturated) return saturated;
|
|
18100
|
+
const result = run2.useCases.get(sc.u.id)?.scenarios[sc.u.scenarios.indexOf(s)];
|
|
18101
|
+
const ms = result?.percentiles[key] ?? 0;
|
|
18102
|
+
const message = `${what} is ${formatMs(ms)} (limit ${formatMs(maxMs)})`;
|
|
18103
|
+
return ms < maxMs ? pass(message) : fail(message, latencyHint(run2, sc.u, s));
|
|
17685
18104
|
}
|
|
17686
18105
|
function saturationHint(run2, n) {
|
|
17687
|
-
const
|
|
17688
|
-
|
|
17689
|
-
|
|
18106
|
+
const label = nodeLabel(run2.diagram, n.id);
|
|
18107
|
+
if (writeBound(n)) {
|
|
18108
|
+
const perShard = n.writeCapacityRps / n.shards;
|
|
18109
|
+
const shards = Math.ceil(n.writeLoadRps / (perShard * HOT));
|
|
18110
|
+
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`;
|
|
18111
|
+
}
|
|
18112
|
+
const rho = n.writeScaling === "shards" ? n.readUtilization : n.utilization;
|
|
18113
|
+
const replicas = Math.ceil(rho * n.replicas / HOT);
|
|
18114
|
+
return `Add replicas to ${label}: x${replicas} keeps it under ${formatPercent(HOT)}, or take load off it (a cache in front, or async work)`;
|
|
17690
18115
|
}
|
|
17691
|
-
function latencyHint(run2, u,
|
|
17692
|
-
const index = result.scenarios.findIndex((s) => s.percentiles[key] === ms);
|
|
17693
|
-
const scenario = u.scenarios[Math.max(0, index)];
|
|
17694
|
-
const lead = u.scenarios.length > 1 ? `The "${scenario.name}" path (${formatPercent(result.scenarios[Math.max(0, index)].share)} of traffic) sets ${key}. ` : "";
|
|
18116
|
+
function latencyHint(run2, u, scenario) {
|
|
17695
18117
|
const hops = criticalPath(u, scenario).flat();
|
|
17696
18118
|
const cost2 = (h) => h.failed ? DEFAULT_TIMEOUT_MS : run2.nodes.get(h.target)?.latencyMs ?? 0;
|
|
17697
18119
|
const slowest = hops.reduce((best, h) => !best || cost2(h) > cost2(best) ? h : best, void 0);
|
|
17698
|
-
if (!slowest) return
|
|
18120
|
+
if (!slowest) return "Shorten the synchronous path of the request.";
|
|
17699
18121
|
const label = nodeLabel(run2.diagram, slowest.target);
|
|
17700
|
-
if (slowest.failed) return
|
|
18122
|
+
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.`;
|
|
18123
|
+
const sized = hops.find((h) => (h.step.sizeBytes ?? 0) > 0);
|
|
17701
18124
|
const node = run2.nodes.get(slowest.target);
|
|
17702
|
-
if (node && node.utilization > HOT) return `${
|
|
17703
|
-
|
|
18125
|
+
if (node && node.utilization > HOT) return `${label} runs at ${formatPercent(node.utilization)} and queues requests; add replicas to it.`;
|
|
18126
|
+
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.` : "";
|
|
18127
|
+
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}`;
|
|
17704
18128
|
}
|
|
17705
18129
|
function availability(run2, u, minPercent) {
|
|
17706
18130
|
const result = run2.useCases.get(u.id);
|
|
@@ -17713,37 +18137,53 @@ function availability(run2, u, minPercent) {
|
|
|
17713
18137
|
const hint = weakest ? `The weakest link is ${nodeLabel(run2.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";
|
|
17714
18138
|
return { ...fail(message, hint), value };
|
|
17715
18139
|
}
|
|
17716
|
-
function writeGap(u, s, accept) {
|
|
18140
|
+
function writeGap(run2, u, s, accept) {
|
|
17717
18141
|
const { requests, beforeResponse } = requestOrder(u, s);
|
|
17718
|
-
const
|
|
17719
|
-
|
|
17720
|
-
|
|
17721
|
-
return "
|
|
18142
|
+
const hits = requests.map((m, i) => ({ m, i })).filter(({ m }) => !m.failed && accept(m.to));
|
|
18143
|
+
const writes = hits.filter(({ m }) => run2.isWrite(m.step));
|
|
18144
|
+
const early = writes.find(({ m, i }) => m.async && i < beforeResponse);
|
|
18145
|
+
if (early) return { kind: "async", step: early.m.step };
|
|
18146
|
+
const late = writes.find(({ i }) => i >= beforeResponse);
|
|
18147
|
+
if (late) return { kind: "after", step: late.m.step };
|
|
18148
|
+
const read = hits.find(({ m, i }) => i < beforeResponse && !m.async);
|
|
18149
|
+
if (read) return { kind: "read", step: read.m.step };
|
|
18150
|
+
return { kind: "none" };
|
|
17722
18151
|
}
|
|
17723
|
-
|
|
17724
|
-
async: "writes only asynchronously (->>)",
|
|
17725
|
-
after: "writes only after responding",
|
|
17726
|
-
none: "never writes"
|
|
17727
|
-
};
|
|
17728
|
-
function writesCheck(u, scenarios, accept, what) {
|
|
18152
|
+
function writesCheck(run2, u, scenarios, accept, what) {
|
|
17729
18153
|
const success = scenarios.filter((s) => s.outcome === "success");
|
|
17730
18154
|
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");
|
|
17731
|
-
const gaps = success.
|
|
18155
|
+
const gaps = success.filter((s) => !writesBeforeResponse(u, s, accept, run2.isWrite));
|
|
17732
18156
|
if (gaps.length === 0) {
|
|
17733
18157
|
const label = success.length === 1 ? `"${u.name}"` : `Every success scenario of "${u.name}"`;
|
|
17734
|
-
return pass(`${label} writes ${what} before responding`);
|
|
18158
|
+
return pass(`${label} writes to ${what} before responding`);
|
|
17735
18159
|
}
|
|
17736
|
-
const details = gaps.map((
|
|
18160
|
+
const details = gaps.map((s) => ({ s, gap: writeGap(run2, u, s, accept) }));
|
|
17737
18161
|
const where = (s) => u.scenarios.length > 1 ? `"${s.name}"` : `"${u.name}"`;
|
|
17738
|
-
const
|
|
17739
|
-
|
|
17740
|
-
|
|
17741
|
-
|
|
18162
|
+
const text = (s, gap) => {
|
|
18163
|
+
switch (gap.kind) {
|
|
18164
|
+
case "async":
|
|
18165
|
+
return `${where(s)} writes to ${what} only asynchronously: ${stepText(gap.step)}`;
|
|
18166
|
+
case "after":
|
|
18167
|
+
return `${where(s)} writes to ${what} only after responding: ${stepText(gap.step)}`;
|
|
18168
|
+
case "read":
|
|
18169
|
+
return `${where(s)} only reads from ${what} before responding: ${stepText(gap.step)} is a read`;
|
|
18170
|
+
case "none":
|
|
18171
|
+
return `${where(s)} never writes to ${what} before responding`;
|
|
18172
|
+
}
|
|
18173
|
+
};
|
|
18174
|
+
const message = details.map(({ s, gap }) => text(s, gap)).join("; ");
|
|
18175
|
+
const hints = {
|
|
18176
|
+
async: "Make the write a synchronous request (->) so it is acknowledged before the response",
|
|
18177
|
+
after: "Move the write before the step that answers the entry request",
|
|
18178
|
+
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",
|
|
18179
|
+
none: `Add a synchronous write (->) to ${what} before the entry request is answered`
|
|
18180
|
+
};
|
|
18181
|
+
return fail(message, hints[details[0].gap.kind]);
|
|
17742
18182
|
}
|
|
17743
18183
|
function durable(run2, name) {
|
|
17744
18184
|
const u = findUseCase(run2.diagram, name);
|
|
17745
18185
|
if (!u) return unknownUseCase(run2, name);
|
|
17746
|
-
return writesCheck(u, u.scenarios, (id) => !!run2.nodes.get(id)?.durable, "
|
|
18186
|
+
return writesCheck(run2, u, u.scenarios, (id) => !!run2.nodes.get(id)?.durable, "a durable store");
|
|
17747
18187
|
}
|
|
17748
18188
|
function survive(run2, target) {
|
|
17749
18189
|
const all = components(run2.diagram);
|
|
@@ -17758,13 +18198,12 @@ function survive(run2, target) {
|
|
|
17758
18198
|
const label = nodeLabel(run2.diagram, node.id);
|
|
17759
18199
|
const replicas = replicasOf(node);
|
|
17760
18200
|
if (replicas >= 2) {
|
|
17761
|
-
const left =
|
|
17762
|
-
|
|
17763
|
-
if (rho >= 1) {
|
|
18201
|
+
const left = usageOf(run2.profiles.get(node.id), replicas - 1, n.readLoadRps, n.writeLoadRps);
|
|
18202
|
+
if (left.saturated) {
|
|
17764
18203
|
failures.push(
|
|
17765
18204
|
fail(
|
|
17766
|
-
`Losing one of ${replicas} ${node.id} replicas leaves ${formatRps(left)} for ${formatRps(n.loadRps)} (${formatPercent(
|
|
17767
|
-
`Add a replica to ${label} (x${replicas + 1}) so the rest carry the load`
|
|
18205
|
+
`Losing one of ${replicas} ${node.id} replicas leaves ${formatRps(left.capacityRps)} for ${formatRps(n.loadRps)} (${formatPercent(left.utilization)})`,
|
|
18206
|
+
writeBound(left) ? saturationHint(run2, { ...n, ...left }) : `Add a replica to ${label} (x${replicas + 1}) so the rest carry the load`
|
|
17768
18207
|
)
|
|
17769
18208
|
);
|
|
17770
18209
|
}
|
|
@@ -17789,20 +18228,34 @@ function survive(run2, target) {
|
|
|
17789
18228
|
return fail(`${failures[0].message}${more}`, failures[0].hint);
|
|
17790
18229
|
}
|
|
17791
18230
|
function cost(run2, max) {
|
|
17792
|
-
const total = run2.analysis
|
|
17793
|
-
const
|
|
18231
|
+
const { totalCostUsd: total, totalEgressUsd: egress } = run2.analysis;
|
|
18232
|
+
const ofWhich = egress > 0 ? `, ${formatUsd(egress)} of it egress` : "";
|
|
18233
|
+
const message = `Total cost is ${formatUsd(total)}${ofWhich} (limit ${formatUsd(max)})`;
|
|
17794
18234
|
if (total <= max) return pass(message);
|
|
17795
|
-
const
|
|
17796
|
-
|
|
18235
|
+
const usd = (x) => formatUsd(x).replace("/month", "");
|
|
18236
|
+
const top = [...run2.analysis.nodes].filter((n) => n.costUsd > 0).sort((a, b) => b.costUsd - a.costUsd).slice(0, 3).map((n) => {
|
|
18237
|
+
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);
|
|
18238
|
+
return `${n.id} ${formatUsd(n.costUsd)}${notes.length ? ` (${notes.join(", ")})` : ""}`;
|
|
18239
|
+
});
|
|
18240
|
+
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." : "";
|
|
18241
|
+
return fail(message, `Biggest items: ${top.join(", ")}. Drop replicas on nodes with low utilisation or pick cheaper techs.${egressHint}`);
|
|
17797
18242
|
}
|
|
17798
18243
|
function flowTest(run2, test) {
|
|
17799
|
-
const
|
|
17800
|
-
const
|
|
18244
|
+
const checks = test.assertions.map((a) => ({ ...assertion(run2, a), loc: a.loc }));
|
|
18245
|
+
const assertions = checks.map(({ passed, message, hint, loc }) => hint === void 0 ? { passed, message, loc } : { passed, message, hint, loc });
|
|
17801
18246
|
if (assertions.length === 0) return { passed: true, message: "No assertions to check", assertions };
|
|
18247
|
+
const failed = checks.filter((a) => !a.passed);
|
|
17802
18248
|
if (failed.length === 0) {
|
|
17803
18249
|
return { passed: true, message: assertions.length === 1 ? assertions[0].message : `All ${assertions.length} assertions hold`, assertions };
|
|
17804
18250
|
}
|
|
17805
|
-
|
|
18251
|
+
const seen = /* @__PURE__ */ new Set();
|
|
18252
|
+
const reported = failed.filter((a) => {
|
|
18253
|
+
const key = a.missing ?? a.message;
|
|
18254
|
+
if (seen.has(key)) return false;
|
|
18255
|
+
seen.add(key);
|
|
18256
|
+
return true;
|
|
18257
|
+
});
|
|
18258
|
+
return { passed: false, message: reported.map((a) => a.message).join("; "), hint: reported[0].hint, assertions };
|
|
17806
18259
|
}
|
|
17807
18260
|
var quote = (s) => `"${s}"`;
|
|
17808
18261
|
function scope(run2, useCase, scenario) {
|
|
@@ -17815,9 +18268,13 @@ function scope(run2, useCase, scenario) {
|
|
|
17815
18268
|
}
|
|
17816
18269
|
function missingScenario(u, scenario) {
|
|
17817
18270
|
const names = u.scenarios.map((s) => quote(s.name)).join(", ");
|
|
17818
|
-
return
|
|
18271
|
+
return {
|
|
18272
|
+
...fail(`${quote(u.name)} has no scenario ${quote(scenario)}${names ? `; it has ${names}` : ""}`, `Add alt "${scenario}" { \u2026 } to "${u.name}"`),
|
|
18273
|
+
missing: `scenario ${u.name} \u203A ${scenario}`
|
|
18274
|
+
};
|
|
17819
18275
|
}
|
|
17820
18276
|
var isCheck = (x) => "passed" in x;
|
|
18277
|
+
var idsOf = (run2, selector) => new Set(selectNodes(run2.diagram, selector).map((n) => n.id));
|
|
17821
18278
|
function assertion(run2, a) {
|
|
17822
18279
|
switch (a.kind) {
|
|
17823
18280
|
case "calls":
|
|
@@ -17827,9 +18284,9 @@ function assertion(run2, a) {
|
|
|
17827
18284
|
case "writesBeforeResponding": {
|
|
17828
18285
|
const sc = scope(run2, a.useCase, a.scenario);
|
|
17829
18286
|
if (isCheck(sc)) return sc;
|
|
17830
|
-
const ids =
|
|
18287
|
+
const ids = idsOf(run2, a.target);
|
|
17831
18288
|
if (ids.size === 0) return noMatch(a.target);
|
|
17832
|
-
return writesCheck(sc.u, sc.scenarios, (id) => ids.has(id), selectorText(a.target));
|
|
18289
|
+
return writesCheck(run2, sc.u, sc.scenarios, (id) => ids.has(id), selectorText(a.target));
|
|
17833
18290
|
}
|
|
17834
18291
|
case "responds":
|
|
17835
18292
|
return responds(run2, a);
|
|
@@ -17842,7 +18299,7 @@ function assertion(run2, a) {
|
|
|
17842
18299
|
case "handlesFailure": {
|
|
17843
18300
|
const u = findUseCase(run2.diagram, a.useCase);
|
|
17844
18301
|
if (!u) return unknownUseCase(run2, a.useCase);
|
|
17845
|
-
const ids =
|
|
18302
|
+
const ids = idsOf(run2, a.target);
|
|
17846
18303
|
if (ids.size === 0) return noMatch(a.target);
|
|
17847
18304
|
const handled = u.scenarios.find((s) => s.outcome === "success" && s.steps.some((step) => step.failed && ids.has(step.toServiceId)));
|
|
17848
18305
|
const what = selectorText(a.target);
|
|
@@ -17861,6 +18318,14 @@ function assertion(run2, a) {
|
|
|
17861
18318
|
if (short.length === 0) return pass(`${nodes.map(has).join(", ")} (minimum ${a.min})`);
|
|
17862
18319
|
return fail(`${short.map(has).join(", ")} (minimum ${a.min})`, `Declare ${short[0].id} with x${a.min}`);
|
|
17863
18320
|
}
|
|
18321
|
+
case "neverWaits":
|
|
18322
|
+
return neverWaits(run2, a);
|
|
18323
|
+
case "after":
|
|
18324
|
+
return after(run2, a);
|
|
18325
|
+
case "senderCalls":
|
|
18326
|
+
return senderCalls(run2, a);
|
|
18327
|
+
case "startsAt":
|
|
18328
|
+
return startsAt(run2, a);
|
|
17864
18329
|
}
|
|
17865
18330
|
}
|
|
17866
18331
|
function noMatch(selector) {
|
|
@@ -17870,17 +18335,18 @@ function calls(run2, a) {
|
|
|
17870
18335
|
const sc = scope(run2, a.useCase, a.scenario);
|
|
17871
18336
|
if (isCheck(sc)) return sc;
|
|
17872
18337
|
const what = selectorText(a.target);
|
|
17873
|
-
const ids =
|
|
17874
|
-
const called = (s) => s.steps.filter((step) => ids.has(step.toServiceId))
|
|
18338
|
+
const ids = idsOf(run2, a.target);
|
|
18339
|
+
const called = (s) => s.steps.filter((step) => ids.has(step.toServiceId));
|
|
17875
18340
|
const calling = sc.scenarios.filter((s) => called(s).length > 0);
|
|
17876
18341
|
const names = (list) => list.map((s) => quote(s.name)).join(", ");
|
|
17877
18342
|
const multi = sc.scenarios.length > 1;
|
|
17878
18343
|
if (a.quantifier === "never") {
|
|
17879
18344
|
if (calling.length === 0) return pass(`${sc.label} never calls ${what}`);
|
|
17880
18345
|
const first = calling[0];
|
|
18346
|
+
const step = called(first)[0];
|
|
17881
18347
|
return fail(
|
|
17882
|
-
`${multi ? quote(first.name) : sc.label} calls ${nodeLabel(run2.diagram,
|
|
17883
|
-
`Take the call to ${
|
|
18348
|
+
`${multi ? quote(first.name) : sc.label} calls ${nodeLabel(run2.diagram, step.toServiceId)}: ${stepText(step)}`,
|
|
18349
|
+
`Take the call to ${step.toServiceId} out of ${quote(first.name)}, e.g. serve it from a cache`
|
|
17884
18350
|
);
|
|
17885
18351
|
}
|
|
17886
18352
|
if (ids.size === 0) return noMatch(a.target);
|
|
@@ -17895,22 +18361,22 @@ function calls(run2, a) {
|
|
|
17895
18361
|
function before(run2, a) {
|
|
17896
18362
|
const sc = scope(run2, a.useCase, a.scenario);
|
|
17897
18363
|
if (isCheck(sc)) return sc;
|
|
17898
|
-
const first =
|
|
17899
|
-
const then =
|
|
18364
|
+
const first = idsOf(run2, a.first);
|
|
18365
|
+
const then = idsOf(run2, a.then);
|
|
17900
18366
|
const [x, y] = [selectorText(a.first), selectorText(a.then)];
|
|
17901
18367
|
if (first.size === 0) return noMatch(a.first);
|
|
17902
18368
|
if (then.size === 0) return noMatch(a.then);
|
|
17903
18369
|
let checked = 0;
|
|
17904
18370
|
for (const s of sc.scenarios) {
|
|
17905
|
-
const order = requestOrder(sc.u, s).requests
|
|
17906
|
-
const iy = order.findIndex((
|
|
18371
|
+
const order = requestOrder(sc.u, s).requests;
|
|
18372
|
+
const iy = order.findIndex((m) => then.has(m.to));
|
|
17907
18373
|
if (iy < 0) continue;
|
|
17908
18374
|
checked++;
|
|
17909
|
-
const ix = order.findIndex((
|
|
18375
|
+
const ix = order.findIndex((m) => first.has(m.to));
|
|
17910
18376
|
if (ix < 0 || ix > iy) {
|
|
17911
18377
|
const where = sc.scenarios.length > 1 || a.scenario ? `In ${quote(s.name)}` : `In ${quote(sc.u.name)}`;
|
|
17912
18378
|
return fail(
|
|
17913
|
-
ix < 0 ? `${where}, ${order[iy]} is called but ${x} never is` : `${where}, ${order[iy]} is called before ${order[ix]}`,
|
|
18379
|
+
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)})`,
|
|
17914
18380
|
`Call ${x} before the first call to ${y}`
|
|
17915
18381
|
);
|
|
17916
18382
|
}
|
|
@@ -17918,6 +18384,100 @@ function before(run2, a) {
|
|
|
17918
18384
|
if (checked === 0) return fail(`${sc.label} never calls ${y}`, `Add a step to ${y} after the call to ${x}`);
|
|
17919
18385
|
return pass(`${sc.label} calls ${x} before ${y}${checked > 1 ? ` in all ${checked} scenarios that call ${y}` : ""}`);
|
|
17920
18386
|
}
|
|
18387
|
+
function neverWaits(run2, a) {
|
|
18388
|
+
const sc = scope(run2, a.useCase, a.scenario);
|
|
18389
|
+
if (isCheck(sc)) return sc;
|
|
18390
|
+
const ids = idsOf(run2, a.target);
|
|
18391
|
+
const what = selectorText(a.target);
|
|
18392
|
+
for (const s of sc.scenarios) {
|
|
18393
|
+
const hop = criticalPath(sc.u, s).flat().find((h) => !h.async && ids.has(h.target));
|
|
18394
|
+
if (hop) {
|
|
18395
|
+
const where = sc.scenarios.length > 1 ? quote(s.name) : sc.label;
|
|
18396
|
+
return fail(
|
|
18397
|
+
`${where} waits for ${nodeLabel(run2.diagram, hop.target)} before responding: ${stepText(hop.step)}${hop.failed ? " times out on the request path" : " is synchronous"}`,
|
|
18398
|
+
`Send it with ->> (through a queue if it must not be lost), or move it after the line that answers the entry request`
|
|
18399
|
+
);
|
|
18400
|
+
}
|
|
18401
|
+
}
|
|
18402
|
+
const calledAtAll = sc.scenarios.some((s) => s.steps.some((step) => ids.has(step.toServiceId)));
|
|
18403
|
+
return pass(`${sc.label} never waits for ${what}${calledAtAll ? "" : ` (it never calls ${what})`}`);
|
|
18404
|
+
}
|
|
18405
|
+
function after(run2, a) {
|
|
18406
|
+
const sc = scope(run2, a.useCase, a.scenario);
|
|
18407
|
+
if (isCheck(sc)) return sc;
|
|
18408
|
+
const targets = idsOf(run2, a.target);
|
|
18409
|
+
const firsts = idsOf(run2, a.after);
|
|
18410
|
+
const [y, x] = [selectorText(a.target), selectorText(a.after)];
|
|
18411
|
+
if (targets.size === 0) return noMatch(a.target);
|
|
18412
|
+
if (firsts.size === 0) return noMatch(a.after);
|
|
18413
|
+
let checked = 0;
|
|
18414
|
+
for (const s of sc.scenarios) {
|
|
18415
|
+
const order = requestOrder(sc.u, s).requests;
|
|
18416
|
+
const ix = order.findIndex((m) => firsts.has(m.to));
|
|
18417
|
+
const iy = order.map((m) => targets.has(m.to)).lastIndexOf(true);
|
|
18418
|
+
if (ix < 0 || iy < 0) continue;
|
|
18419
|
+
checked++;
|
|
18420
|
+
if (iy < ix) {
|
|
18421
|
+
const where = sc.scenarios.length > 1 || a.scenario ? `In ${quote(s.name)}` : `In ${quote(sc.u.name)}`;
|
|
18422
|
+
return fail(
|
|
18423
|
+
`${where}, the last call to ${y} (${stepText(order[iy].step)}) comes before the first call to ${x} (${stepText(order[ix].step)})`,
|
|
18424
|
+
`Call ${y} after ${x}: move the step to ${order[iy].to} below the call to ${order[ix].to}`
|
|
18425
|
+
);
|
|
18426
|
+
}
|
|
18427
|
+
}
|
|
18428
|
+
if (checked === 0) {
|
|
18429
|
+
const callsY = sc.scenarios.some((s) => s.steps.some((step) => targets.has(step.toServiceId)));
|
|
18430
|
+
const callsX = sc.scenarios.some((s) => s.steps.some((step) => firsts.has(step.toServiceId)));
|
|
18431
|
+
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`;
|
|
18432
|
+
return fail(`${sc.label} ${why}`, `Add a step to ${y} after the call to ${x}`);
|
|
18433
|
+
}
|
|
18434
|
+
return pass(`${sc.label} calls ${y} after ${x}${checked > 1 ? ` in all ${checked} scenarios that call both` : ""}`);
|
|
18435
|
+
}
|
|
18436
|
+
function senderCalls(run2, a) {
|
|
18437
|
+
let useCases = run2.diagram.useCases;
|
|
18438
|
+
if (a.useCase !== void 0) {
|
|
18439
|
+
const u = findUseCase(run2.diagram, a.useCase);
|
|
18440
|
+
if (!u) return unknownUseCase(run2, a.useCase);
|
|
18441
|
+
useCases = [u];
|
|
18442
|
+
}
|
|
18443
|
+
const from = idsOf(run2, a.from);
|
|
18444
|
+
const to = idsOf(run2, a.to);
|
|
18445
|
+
const [x, y] = [selectorText(a.from), selectorText(a.to)];
|
|
18446
|
+
const within = a.useCase !== void 0 ? ` in ${quote(useCases[0].name)}` : "";
|
|
18447
|
+
for (const u of useCases) {
|
|
18448
|
+
for (const s of u.scenarios) {
|
|
18449
|
+
const step = s.steps.find((st) => from.has(st.fromServiceId) && to.has(st.toServiceId));
|
|
18450
|
+
if (!step) continue;
|
|
18451
|
+
const where = u.scenarios.length > 1 ? `${quote(u.name)} scenario ${quote(s.name)}` : quote(u.name);
|
|
18452
|
+
if (a.quantifier === "some") return pass(`${x} calls ${y}${within}: ${stepText(step)} in ${where}`);
|
|
18453
|
+
return fail(
|
|
18454
|
+
`${x} calls ${y}${within}: ${stepText(step)} in ${where}`,
|
|
18455
|
+
`Let another node make this call (e.g. the client uploads to storage with a presigned URL), or remove the step`
|
|
18456
|
+
);
|
|
18457
|
+
}
|
|
18458
|
+
}
|
|
18459
|
+
if (a.quantifier === "never") return pass(`${x} never calls ${y}${within}`);
|
|
18460
|
+
if (from.size === 0) return noMatch(a.from);
|
|
18461
|
+
if (to.size === 0) return noMatch(a.to);
|
|
18462
|
+
return fail(`No step is sent by ${x} to ${y}${within}`, `Add a step ${[...from][0]} -> ${[...to][0]}${within}`);
|
|
18463
|
+
}
|
|
18464
|
+
function startsAt(run2, a) {
|
|
18465
|
+
const u = findUseCase(run2.diagram, a.useCase);
|
|
18466
|
+
if (!u) return unknownUseCase(run2, a.useCase);
|
|
18467
|
+
const ids = idsOf(run2, a.target);
|
|
18468
|
+
const what = selectorText(a.target);
|
|
18469
|
+
if (ids.size === 0) return noMatch(a.target);
|
|
18470
|
+
const entries = u.scenarios.map((s) => s.steps[0]).filter((s) => !!s);
|
|
18471
|
+
if (entries.length === 0) return fail(`${quote(u.name)} has no steps`, `Start "${u.name}" with a step sent by ${what}`);
|
|
18472
|
+
const wrong = entries.find((s) => !ids.has(s.fromServiceId));
|
|
18473
|
+
if (wrong) {
|
|
18474
|
+
return fail(
|
|
18475
|
+
`${quote(u.name)} starts at ${nodeLabel(run2.diagram, wrong.fromServiceId)}, not ${what}: ${stepText(wrong)}`,
|
|
18476
|
+
`Make the first step of "${u.name}" a step sent by ${what}`
|
|
18477
|
+
);
|
|
18478
|
+
}
|
|
18479
|
+
return pass(`${quote(u.name)} starts at ${nodeLabel(run2.diagram, entries[0].fromServiceId)}`);
|
|
18480
|
+
}
|
|
17921
18481
|
function statusMatches(status, wanted) {
|
|
17922
18482
|
if (status === void 0) return false;
|
|
17923
18483
|
const cls = /^([1-5])xx$/i.exec(wanted);
|
|
@@ -17932,19 +18492,18 @@ function responds(run2, a) {
|
|
|
17932
18492
|
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}`);
|
|
17933
18493
|
}
|
|
17934
18494
|
function noPath(run2, from, to) {
|
|
17935
|
-
const sources =
|
|
17936
|
-
const targets =
|
|
18495
|
+
const sources = idsOf(run2, from);
|
|
18496
|
+
const targets = idsOf(run2, to);
|
|
17937
18497
|
const [x, y] = [selectorText(from), selectorText(to)];
|
|
17938
|
-
const line = (loc) => `${loc.file ? `${loc.file}:` : "line "}${loc.line}`;
|
|
17939
18498
|
const hint = `Route it through a node that guards ${y} (a gateway or a service) instead of going there directly`;
|
|
17940
18499
|
const edge = run2.diagram.edges.find((e) => sources.has(e.source) && targets.has(e.target));
|
|
17941
|
-
if (edge) return fail(`The connection ${edge.source} -> ${edge.target} (${
|
|
18500
|
+
if (edge) return fail(`The connection ${edge.source} -> ${edge.target} (${lineOf(edge.loc)}) goes directly from ${x} to ${y}`, hint);
|
|
17942
18501
|
for (const u of run2.diagram.useCases) {
|
|
17943
18502
|
for (const s of u.scenarios) {
|
|
17944
18503
|
const step = s.steps.find((st) => sources.has(st.fromServiceId) && targets.has(st.toServiceId));
|
|
17945
18504
|
if (step) {
|
|
17946
18505
|
const where = u.scenarios.length > 1 ? `${quote(u.name)} scenario ${quote(s.name)}` : quote(u.name);
|
|
17947
|
-
return fail(`The step ${step.fromServiceId} -> ${step.toServiceId} in ${where} (${
|
|
18506
|
+
return fail(`The step ${step.fromServiceId} -> ${step.toServiceId} in ${where} (${lineOf(step.loc)}) goes directly from ${x} to ${y}`, hint);
|
|
17948
18507
|
}
|
|
17949
18508
|
}
|
|
17950
18509
|
}
|
|
@@ -18002,8 +18561,9 @@ var SIM_USAGE = ` proschi test [--format text|github|json] <file|dir>...
|
|
|
18002
18561
|
var SIM_HELP = `test Runs the requirements and test blocks of each file (imports are
|
|
18003
18562
|
followed) against the simulation. Exits with 1 if any fails or a file
|
|
18004
18563
|
has errors.
|
|
18005
|
-
analyze Prints the capacity table: load
|
|
18006
|
-
and cost per node; latency
|
|
18564
|
+
analyze Prints the capacity table: reads and writes (load of capacity and
|
|
18565
|
+
utilisation), latency, availability, egress and cost per node; latency
|
|
18566
|
+
percentiles per use case and scenario.`;
|
|
18007
18567
|
function testFiles(files, load = parseFile) {
|
|
18008
18568
|
return files.map((file) => {
|
|
18009
18569
|
const { diagram, diagnostics } = load(file);
|
|
@@ -18111,27 +18671,38 @@ function table(rows, left = 1) {
|
|
|
18111
18671
|
const widths = rows[0].map((_, c) => Math.max(...rows.map((r) => r[c].length)));
|
|
18112
18672
|
return rows.map((r) => r.map((cell, c) => c < left ? cell.padEnd(widths[c]) : cell.padStart(widths[c])).join(" ").trimEnd());
|
|
18113
18673
|
}
|
|
18674
|
+
function accessCell(load, capacity, utilization) {
|
|
18675
|
+
return load > 0 ? `${formatRps(load).replace(" rps", "")}/${formatRps(capacity)} ${formatPercent(utilization)}` : "-";
|
|
18676
|
+
}
|
|
18677
|
+
function formatGb(gb) {
|
|
18678
|
+
const n = (x, digits) => Number(x.toFixed(digits)).toLocaleString("en-US");
|
|
18679
|
+
return gb >= 1e3 ? `${n(gb / 1e3, 1)} TB` : `${n(gb, gb < 10 ? 1 : 0)} GB`;
|
|
18680
|
+
}
|
|
18681
|
+
function egressCell(n) {
|
|
18682
|
+
return n.egressGbPerMonth > 0 ? `${formatUsd(n.egressUsd).replace("/month", "")} (${formatGb(n.egressGbPerMonth)})` : "-";
|
|
18683
|
+
}
|
|
18114
18684
|
function capacityReport(diagram, analysis, file) {
|
|
18115
18685
|
const lines = [diagram.title ? `${diagram.title} (${file})` : file, ""];
|
|
18116
18686
|
if ((diagram.traffic ?? []).length === 0) lines.push("No traffic { \u2026 } block: loads are zero and latencies are at idle.", "");
|
|
18117
18687
|
const nodes = analysis.nodes.filter((n) => n.kind !== "client" && n.kind !== "other");
|
|
18118
18688
|
lines.push(
|
|
18119
18689
|
...table([
|
|
18120
|
-
["Node", "Kind", "Replicas", "
|
|
18690
|
+
["Node", "Kind", "Replicas", "Reads", "Writes", "Util", "Latency", "Availability", "Egress/month", "Cost/month"],
|
|
18121
18691
|
...nodes.map((n) => [
|
|
18122
18692
|
n.id,
|
|
18123
18693
|
n.kind,
|
|
18124
|
-
String(n.replicas),
|
|
18125
|
-
|
|
18126
|
-
|
|
18694
|
+
n.shards > 1 ? `${n.replicas}x${n.shards} shards` : String(n.replicas),
|
|
18695
|
+
accessCell(n.readLoadRps, n.readCapacityRps, n.readUtilization),
|
|
18696
|
+
accessCell(n.writeLoadRps, n.writeCapacityRps, n.writeUtilization),
|
|
18127
18697
|
`${formatPercent(n.utilization)}${n.saturated ? " SATURATED" : n.utilization > HOT ? " hot" : ""}`,
|
|
18128
18698
|
formatMs(n.latencyMs),
|
|
18129
18699
|
formatAvailability(n.availability),
|
|
18700
|
+
egressCell(n),
|
|
18130
18701
|
formatUsd(n.costUsd).replace("/month", "")
|
|
18131
18702
|
])
|
|
18132
18703
|
], 2),
|
|
18133
18704
|
"",
|
|
18134
|
-
`Total cost: ${formatUsd(analysis.totalCostUsd)}`,
|
|
18705
|
+
`Total cost: ${formatUsd(analysis.totalCostUsd)}${analysis.totalEgressUsd > 0 ? ` (${formatUsd(analysis.totalEgressUsd)} of it egress)` : ""}`,
|
|
18135
18706
|
""
|
|
18136
18707
|
);
|
|
18137
18708
|
if (analysis.useCases.length) {
|
|
@@ -18185,7 +18756,7 @@ function checkFiles(files, extra) {
|
|
|
18185
18756
|
}
|
|
18186
18757
|
|
|
18187
18758
|
// src/cli.ts
|
|
18188
|
-
var VERSION = true ? "0.
|
|
18759
|
+
var VERSION = true ? "0.4.0" : "dev";
|
|
18189
18760
|
var USAGE = `Usage:
|
|
18190
18761
|
proschi check [--strict] [--format text|github|json] [--openapi <node>=<spec>]... <file|dir>...
|
|
18191
18762
|
proschi parse <file>
|