@retinue/agentkit 0.1.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/LICENSE +22 -0
- package/README.md +310 -0
- package/dist/adapters/bullmq/consumer.d.ts +33 -0
- package/dist/adapters/bullmq/consumer.js +41 -0
- package/dist/adapters/bullmq/dispatcher.d.ts +74 -0
- package/dist/adapters/bullmq/dispatcher.js +160 -0
- package/dist/adapters/bullmq/export.d.ts +31 -0
- package/dist/adapters/bullmq/export.js +53 -0
- package/dist/adapters/bullmq/extraction.d.ts +42 -0
- package/dist/adapters/bullmq/extraction.js +63 -0
- package/dist/adapters/bullmq/index.d.ts +13 -0
- package/dist/adapters/bullmq/index.js +13 -0
- package/dist/adapters/bullmq/lock.d.ts +77 -0
- package/dist/adapters/bullmq/lock.js +126 -0
- package/dist/adapters/bullmq/queue.d.ts +50 -0
- package/dist/adapters/bullmq/queue.js +81 -0
- package/dist/adapters/memory/artifact-exports.d.ts +11 -0
- package/dist/adapters/memory/artifact-exports.js +102 -0
- package/dist/adapters/memory/artifacts.d.ts +15 -0
- package/dist/adapters/memory/artifacts.js +134 -0
- package/dist/adapters/memory/blobs.d.ts +7 -0
- package/dist/adapters/memory/blobs.js +27 -0
- package/dist/adapters/memory/evaluation.d.ts +18 -0
- package/dist/adapters/memory/evaluation.js +148 -0
- package/dist/adapters/memory/files.d.ts +27 -0
- package/dist/adapters/memory/files.js +0 -0
- package/dist/adapters/memory/flows.d.ts +16 -0
- package/dist/adapters/memory/flows.js +117 -0
- package/dist/adapters/memory/hitl.d.ts +9 -0
- package/dist/adapters/memory/hitl.js +130 -0
- package/dist/adapters/memory/idempotency.d.ts +13 -0
- package/dist/adapters/memory/idempotency.js +32 -0
- package/dist/adapters/memory/index.d.ts +39 -0
- package/dist/adapters/memory/index.js +107 -0
- package/dist/adapters/memory/knowledge.d.ts +43 -0
- package/dist/adapters/memory/knowledge.js +248 -0
- package/dist/adapters/memory/mcp.d.ts +9 -0
- package/dist/adapters/memory/mcp.js +37 -0
- package/dist/adapters/memory/message-store.d.ts +17 -0
- package/dist/adapters/memory/message-store.js +70 -0
- package/dist/adapters/memory/principal-memory.d.ts +7 -0
- package/dist/adapters/memory/principal-memory.js +83 -0
- package/dist/adapters/memory/runtime.d.ts +29 -0
- package/dist/adapters/memory/runtime.js +0 -0
- package/dist/adapters/memory/sessions.d.ts +29 -0
- package/dist/adapters/memory/sessions.js +0 -0
- package/dist/adapters/memory/skills.d.ts +10 -0
- package/dist/adapters/memory/skills.js +41 -0
- package/dist/adapters/memory/thread-summary.d.ts +7 -0
- package/dist/adapters/memory/thread-summary.js +29 -0
- package/dist/adapters/memory/usage-limits.d.ts +13 -0
- package/dist/adapters/memory/usage-limits.js +72 -0
- package/dist/adapters/memory/usage.d.ts +16 -0
- package/dist/adapters/memory/usage.js +279 -0
- package/dist/adapters/otel/index.d.ts +111 -0
- package/dist/adapters/otel/index.js +133 -0
- package/dist/adapters/postgres/artifact-exports.d.ts +12 -0
- package/dist/adapters/postgres/artifact-exports.js +117 -0
- package/dist/adapters/postgres/artifacts.d.ts +16 -0
- package/dist/adapters/postgres/artifacts.js +172 -0
- package/dist/adapters/postgres/checkpoint-store.d.ts +16 -0
- package/dist/adapters/postgres/checkpoint-store.js +34 -0
- package/dist/adapters/postgres/config.d.ts +15 -0
- package/dist/adapters/postgres/config.js +187 -0
- package/dist/adapters/postgres/conversation-store.d.ts +4 -0
- package/dist/adapters/postgres/conversation-store.js +82 -0
- package/dist/adapters/postgres/evaluation.d.ts +17 -0
- package/dist/adapters/postgres/evaluation.js +193 -0
- package/dist/adapters/postgres/file-content.d.ts +30 -0
- package/dist/adapters/postgres/file-content.js +111 -0
- package/dist/adapters/postgres/files.d.ts +19 -0
- package/dist/adapters/postgres/files.js +209 -0
- package/dist/adapters/postgres/flows.d.ts +20 -0
- package/dist/adapters/postgres/flows.js +206 -0
- package/dist/adapters/postgres/hitl.d.ts +5 -0
- package/dist/adapters/postgres/hitl.js +247 -0
- package/dist/adapters/postgres/index.d.ts +35 -0
- package/dist/adapters/postgres/index.js +35 -0
- package/dist/adapters/postgres/knowledge.d.ts +48 -0
- package/dist/adapters/postgres/knowledge.js +255 -0
- package/dist/adapters/postgres/memory.d.ts +14 -0
- package/dist/adapters/postgres/memory.js +194 -0
- package/dist/adapters/postgres/message-store.d.ts +11 -0
- package/dist/adapters/postgres/message-store.js +145 -0
- package/dist/adapters/postgres/migrations.d.ts +69 -0
- package/dist/adapters/postgres/migrations.js +1594 -0
- package/dist/adapters/postgres/pg-executor.d.ts +19 -0
- package/dist/adapters/postgres/pg-executor.js +32 -0
- package/dist/adapters/postgres/retention.d.ts +26 -0
- package/dist/adapters/postgres/retention.js +59 -0
- package/dist/adapters/postgres/rollups.d.ts +17 -0
- package/dist/adapters/postgres/rollups.js +267 -0
- package/dist/adapters/postgres/run-coordinator.d.ts +5 -0
- package/dist/adapters/postgres/run-coordinator.js +98 -0
- package/dist/adapters/postgres/run-event-log.d.ts +26 -0
- package/dist/adapters/postgres/run-event-log.js +30 -0
- package/dist/adapters/postgres/run-store.d.ts +4 -0
- package/dist/adapters/postgres/run-store.js +199 -0
- package/dist/adapters/postgres/schema.d.ts +39 -0
- package/dist/adapters/postgres/schema.js +70 -0
- package/dist/adapters/postgres/session-state.d.ts +7 -0
- package/dist/adapters/postgres/session-state.js +99 -0
- package/dist/adapters/postgres/sql.d.ts +8 -0
- package/dist/adapters/postgres/sql.js +2 -0
- package/dist/adapters/postgres/transaction.d.ts +37 -0
- package/dist/adapters/postgres/transaction.js +93 -0
- package/dist/adapters/postgres/unit-of-work.d.ts +18 -0
- package/dist/adapters/postgres/unit-of-work.js +8 -0
- package/dist/adapters/postgres/usage-limits.d.ts +15 -0
- package/dist/adapters/postgres/usage-limits.js +136 -0
- package/dist/adapters/postgres/usage.d.ts +15 -0
- package/dist/adapters/postgres/usage.js +226 -0
- package/dist/adapters/redis/index.d.ts +9 -0
- package/dist/adapters/redis/index.js +9 -0
- package/dist/adapters/redis/realtime.d.ts +74 -0
- package/dist/adapters/redis/realtime.js +112 -0
- package/dist/adapters/supabase/index.d.ts +88 -0
- package/dist/adapters/supabase/index.js +84 -0
- package/dist/adapters/supabase/realtime.d.ts +30 -0
- package/dist/adapters/supabase/realtime.js +53 -0
- package/dist/adapters/supabase/rls.d.ts +99 -0
- package/dist/adapters/supabase/rls.js +216 -0
- package/dist/adapters/supabase/storage.d.ts +50 -0
- package/dist/adapters/supabase/storage.js +207 -0
- package/dist/agents/agent.d.ts +66 -0
- package/dist/agents/agent.js +209 -0
- package/dist/agents/define.d.ts +21 -0
- package/dist/agents/define.js +63 -0
- package/dist/agents/engine.d.ts +98 -0
- package/dist/agents/engine.js +462 -0
- package/dist/agents/index.d.ts +50 -0
- package/dist/agents/index.js +17 -0
- package/dist/artifacts/index.d.ts +114 -0
- package/dist/artifacts/index.js +219 -0
- package/dist/authorization/index.d.ts +76 -0
- package/dist/authorization/index.js +76 -0
- package/dist/capabilities/index.d.ts +120 -0
- package/dist/capabilities/index.js +167 -0
- package/dist/capabilities/runtime.d.ts +89 -0
- package/dist/capabilities/runtime.js +84 -0
- package/dist/citations/index.d.ts +161 -0
- package/dist/citations/index.js +182 -0
- package/dist/context/assembler.d.ts +82 -0
- package/dist/context/assembler.js +129 -0
- package/dist/context/compaction.d.ts +45 -0
- package/dist/context/compaction.js +55 -0
- package/dist/context/index.d.ts +75 -0
- package/dist/context/index.js +17 -0
- package/dist/core/content-parts.d.ts +194 -0
- package/dist/core/content-parts.js +23 -0
- package/dist/core/context.d.ts +51 -0
- package/dist/core/context.js +9 -0
- package/dist/core/env.d.ts +25 -0
- package/dist/core/env.js +41 -0
- package/dist/core/errors.d.ts +30 -0
- package/dist/core/errors.js +65 -0
- package/dist/core/events.d.ts +139 -0
- package/dist/core/events.js +99 -0
- package/dist/core/ids.d.ts +52 -0
- package/dist/core/ids.js +13 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.js +9 -0
- package/dist/core/tokens.d.ts +22 -0
- package/dist/core/tokens.js +22 -0
- package/dist/core/validation.d.ts +34 -0
- package/dist/core/validation.js +176 -0
- package/dist/documents/extraction.d.ts +121 -0
- package/dist/documents/extraction.js +293 -0
- package/dist/documents/index.d.ts +199 -0
- package/dist/documents/index.js +65 -0
- package/dist/documents/parsers/pdf.d.ts +47 -0
- package/dist/documents/parsers/pdf.js +508 -0
- package/dist/documents/parsers/text.d.ts +59 -0
- package/dist/documents/parsers/text.js +325 -0
- package/dist/documents/read-tool.d.ts +52 -0
- package/dist/documents/read-tool.js +109 -0
- package/dist/documents/render.d.ts +29 -0
- package/dist/documents/render.js +59 -0
- package/dist/documents/vision.d.ts +159 -0
- package/dist/documents/vision.js +214 -0
- package/dist/entries/adapters-bullmq.d.ts +8 -0
- package/dist/entries/adapters-bullmq.js +8 -0
- package/dist/entries/adapters-otel.d.ts +13 -0
- package/dist/entries/adapters-otel.js +13 -0
- package/dist/entries/adapters-postgres.d.ts +10 -0
- package/dist/entries/adapters-postgres.js +10 -0
- package/dist/entries/adapters-redis.d.ts +3 -0
- package/dist/entries/adapters-redis.js +3 -0
- package/dist/entries/context.d.ts +20 -0
- package/dist/entries/context.js +20 -0
- package/dist/entries/flows.d.ts +15 -0
- package/dist/entries/flows.js +15 -0
- package/dist/entries/hitl.d.ts +10 -0
- package/dist/entries/hitl.js +10 -0
- package/dist/entries/knowledge.d.ts +18 -0
- package/dist/entries/knowledge.js +19 -0
- package/dist/entries/mcp.d.ts +10 -0
- package/dist/entries/mcp.js +10 -0
- package/dist/entries/observability.d.ts +14 -0
- package/dist/entries/observability.js +16 -0
- package/dist/entries/persistence.d.ts +11 -0
- package/dist/entries/persistence.js +11 -0
- package/dist/entries/providers.d.ts +14 -0
- package/dist/entries/providers.js +14 -0
- package/dist/entries/runtime.d.ts +13 -0
- package/dist/entries/runtime.js +13 -0
- package/dist/entries/server.d.ts +24 -0
- package/dist/entries/server.js +24 -0
- package/dist/entries/tools.d.ts +21 -0
- package/dist/entries/tools.js +21 -0
- package/dist/entries/usage.d.ts +10 -0
- package/dist/entries/usage.js +10 -0
- package/dist/evaluation/gate.d.ts +168 -0
- package/dist/evaluation/gate.js +180 -0
- package/dist/evaluation/graders.d.ts +125 -0
- package/dist/evaluation/graders.js +203 -0
- package/dist/evaluation/index.d.ts +120 -0
- package/dist/evaluation/index.js +183 -0
- package/dist/evaluation/judge.d.ts +75 -0
- package/dist/evaluation/judge.js +111 -0
- package/dist/export/index.d.ts +162 -0
- package/dist/export/index.js +363 -0
- package/dist/export/markdown.d.ts +19 -0
- package/dist/export/markdown.js +29 -0
- package/dist/export/pdf.d.ts +73 -0
- package/dist/export/pdf.js +407 -0
- package/dist/files/context.d.ts +97 -0
- package/dist/files/context.js +185 -0
- package/dist/files/index.d.ts +210 -0
- package/dist/files/index.js +338 -0
- package/dist/files/read-tool.d.ts +81 -0
- package/dist/files/read-tool.js +163 -0
- package/dist/files/turn-parts.d.ts +96 -0
- package/dist/files/turn-parts.js +171 -0
- package/dist/flows/index.d.ts +270 -0
- package/dist/flows/index.js +62 -0
- package/dist/flows/interpreter.d.ts +146 -0
- package/dist/flows/interpreter.js +426 -0
- package/dist/flows/runner.d.ts +145 -0
- package/dist/flows/runner.js +270 -0
- package/dist/graphql/index.d.ts +8 -0
- package/dist/graphql/index.js +8 -0
- package/dist/graphql/resolvers.d.ts +237 -0
- package/dist/graphql/resolvers.js +253 -0
- package/dist/graphql/schema.d.ts +11 -0
- package/dist/graphql/schema.js +258 -0
- package/dist/graphql/sse.d.ts +77 -0
- package/dist/graphql/sse.js +100 -0
- package/dist/hitl/approved-execution.d.ts +127 -0
- package/dist/hitl/approved-execution.js +177 -0
- package/dist/hitl/index.d.ts +79 -0
- package/dist/hitl/index.js +12 -0
- package/dist/hitl/service.d.ts +221 -0
- package/dist/hitl/service.js +268 -0
- package/dist/idempotency/index.d.ts +70 -0
- package/dist/idempotency/index.js +59 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.js +60 -0
- package/dist/knowledge/chunking.d.ts +57 -0
- package/dist/knowledge/chunking.js +158 -0
- package/dist/knowledge/index.d.ts +119 -0
- package/dist/knowledge/index.js +166 -0
- package/dist/knowledge/retrieval.d.ts +146 -0
- package/dist/knowledge/retrieval.js +170 -0
- package/dist/loadtest/harness.d.ts +168 -0
- package/dist/loadtest/harness.js +507 -0
- package/dist/loadtest/index.d.ts +13 -0
- package/dist/loadtest/index.js +13 -0
- package/dist/loadtest/injection.d.ts +89 -0
- package/dist/loadtest/injection.js +147 -0
- package/dist/loadtest/metrics.d.ts +197 -0
- package/dist/loadtest/metrics.js +160 -0
- package/dist/loadtest/runbooks.d.ts +28 -0
- package/dist/loadtest/runbooks.js +159 -0
- package/dist/loadtest/scenario.d.ts +104 -0
- package/dist/loadtest/scenario.js +208 -0
- package/dist/mcp/egress.d.ts +53 -0
- package/dist/mcp/egress.js +115 -0
- package/dist/mcp/index.d.ts +93 -0
- package/dist/mcp/index.js +33 -0
- package/dist/mcp/provider.d.ts +62 -0
- package/dist/mcp/provider.js +0 -0
- package/dist/models/index.d.ts +98 -0
- package/dist/models/index.js +74 -0
- package/dist/models/pricing.d.ts +24 -0
- package/dist/models/pricing.js +37 -0
- package/dist/models/provider-factory.d.ts +31 -0
- package/dist/models/provider-factory.js +67 -0
- package/dist/models/streaming.d.ts +145 -0
- package/dist/models/streaming.js +272 -0
- package/dist/models/vision.d.ts +38 -0
- package/dist/models/vision.js +62 -0
- package/dist/persistence/index.d.ts +1654 -0
- package/dist/persistence/index.js +226 -0
- package/dist/principal-memory/index.d.ts +106 -0
- package/dist/principal-memory/index.js +89 -0
- package/dist/retention/index.d.ts +89 -0
- package/dist/retention/index.js +70 -0
- package/dist/runtime/checkpoint.d.ts +37 -0
- package/dist/runtime/checkpoint.js +22 -0
- package/dist/runtime/index.d.ts +118 -0
- package/dist/runtime/index.js +69 -0
- package/dist/runtime/retry.d.ts +95 -0
- package/dist/runtime/retry.js +126 -0
- package/dist/runtime/serialization.d.ts +85 -0
- package/dist/runtime/serialization.js +95 -0
- package/dist/runtime/streaming.d.ts +54 -0
- package/dist/runtime/streaming.js +115 -0
- package/dist/runtime/worker.d.ts +130 -0
- package/dist/runtime/worker.js +405 -0
- package/dist/security/checklist.d.ts +53 -0
- package/dist/security/checklist.js +204 -0
- package/dist/security/findings.d.ts +56 -0
- package/dist/security/findings.js +168 -0
- package/dist/security/index.d.ts +14 -0
- package/dist/security/index.js +14 -0
- package/dist/security/prompt-safety.d.ts +100 -0
- package/dist/security/prompt-safety.js +133 -0
- package/dist/server/boot.d.ts +32 -0
- package/dist/server/boot.js +36 -0
- package/dist/server/cli-worker.d.ts +37 -0
- package/dist/server/cli-worker.js +151 -0
- package/dist/server/cli.d.ts +27 -0
- package/dist/server/cli.js +74 -0
- package/dist/server/config.d.ts +42 -0
- package/dist/server/config.js +127 -0
- package/dist/server/health.d.ts +59 -0
- package/dist/server/health.js +90 -0
- package/dist/server/host.d.ts +39 -0
- package/dist/server/host.js +124 -0
- package/dist/server/index.d.ts +15 -0
- package/dist/server/index.js +15 -0
- package/dist/server/main.d.ts +16 -0
- package/dist/server/main.js +31 -0
- package/dist/server/sse-route.d.ts +21 -0
- package/dist/server/sse-route.js +282 -0
- package/dist/skills/index.d.ts +67 -0
- package/dist/skills/index.js +31 -0
- package/dist/skills/resolver.d.ts +54 -0
- package/dist/skills/resolver.js +121 -0
- package/dist/teams/index.d.ts +93 -0
- package/dist/teams/index.js +207 -0
- package/dist/telemetry/index.d.ts +157 -0
- package/dist/telemetry/index.js +71 -0
- package/dist/telemetry/instrument.d.ts +108 -0
- package/dist/telemetry/instrument.js +232 -0
- package/dist/telemetry/log-events.d.ts +17 -0
- package/dist/telemetry/log-events.js +58 -0
- package/dist/telemetry/metrics.d.ts +123 -0
- package/dist/telemetry/metrics.js +135 -0
- package/dist/telemetry/noop.d.ts +39 -0
- package/dist/telemetry/noop.js +143 -0
- package/dist/telemetry/redaction.d.ts +64 -0
- package/dist/telemetry/redaction.js +153 -0
- package/dist/telemetry/spans.d.ts +56 -0
- package/dist/telemetry/spans.js +78 -0
- package/dist/telemetry/trace-context.d.ts +55 -0
- package/dist/telemetry/trace-context.js +60 -0
- package/dist/toolkit/compute.d.ts +53 -0
- package/dist/toolkit/compute.js +152 -0
- package/dist/toolkit/data.d.ts +98 -0
- package/dist/toolkit/data.js +235 -0
- package/dist/toolkit/http.d.ts +113 -0
- package/dist/toolkit/http.js +205 -0
- package/dist/toolkit/index.d.ts +21 -0
- package/dist/toolkit/index.js +17 -0
- package/dist/toolkit/web.d.ts +107 -0
- package/dist/toolkit/web.js +147 -0
- package/dist/tools/define.d.ts +25 -0
- package/dist/tools/define.js +45 -0
- package/dist/tools/delegating.d.ts +132 -0
- package/dist/tools/delegating.js +211 -0
- package/dist/tools/index.d.ts +129 -0
- package/dist/tools/index.js +33 -0
- package/dist/tools/library/compute.d.ts +11 -0
- package/dist/tools/library/compute.js +46 -0
- package/dist/tools/library/data.d.ts +16 -0
- package/dist/tools/library/data.js +92 -0
- package/dist/tools/library/http.d.ts +28 -0
- package/dist/tools/library/http.js +71 -0
- package/dist/tools/library/index.d.ts +97 -0
- package/dist/tools/library/index.js +134 -0
- package/dist/tools/library/knowledge.d.ts +39 -0
- package/dist/tools/library/knowledge.js +58 -0
- package/dist/tools/library/web.d.ts +19 -0
- package/dist/tools/library/web.js +65 -0
- package/dist/tools/meta-tools.d.ts +19 -0
- package/dist/tools/meta-tools.js +36 -0
- package/dist/tools/registry.d.ts +146 -0
- package/dist/tools/registry.js +291 -0
- package/dist/usage/index.d.ts +105 -0
- package/dist/usage/index.js +20 -0
- package/dist/usage/quota.d.ts +258 -0
- package/dist/usage/quota.js +510 -0
- package/dist/usage/recorder.d.ts +29 -0
- package/dist/usage/recorder.js +96 -0
- package/dist/usage/rollups.d.ts +121 -0
- package/dist/usage/rollups.js +157 -0
- package/dist/worker/export.d.ts +57 -0
- package/dist/worker/export.js +81 -0
- package/dist/worker/extraction.d.ts +57 -0
- package/dist/worker/extraction.js +84 -0
- package/dist/worker/main.d.ts +103 -0
- package/dist/worker/main.js +159 -0
- package/package.json +187 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The load and soak driver — AC-1, AC-2, AC-3, AC-4.
|
|
3
|
+
*
|
|
4
|
+
* Drives the **real durable path**: a real Postgres schema, a real BullMQ queue on a real Redis, several real
|
|
5
|
+
* `WorkerRuntime` instances competing for work, real atomic claims, real leases, real checkpoints and a real
|
|
6
|
+
* event log. The only synthetic part is the agent engine, and that is not a shortcut — see `scenario.ts`.
|
|
7
|
+
*
|
|
8
|
+
* What this is *not* is a request against a deployed HTTP endpoint. #144 asks for that, and there is no deployed
|
|
9
|
+
* instance yet; the layers this cannot reach are the GraphQL server and the process boundary between host and
|
|
10
|
+
* worker. Everything below them is exercised for real. That boundary is stated in the report the harness writes,
|
|
11
|
+
* not left for someone to infer from a green tick.
|
|
12
|
+
*
|
|
13
|
+
* Structured so the driver returns **data** and the judging happens in `metrics.ts` and `injection.ts`. A
|
|
14
|
+
* harness that both ran the load and decided whether it passed would be a harness whose verdict nobody could
|
|
15
|
+
* test, and the verdict is the part most likely to be quietly wrong.
|
|
16
|
+
*/
|
|
17
|
+
import { createPostgresCheckpointStore } from "../adapters/postgres/checkpoint-store.js";
|
|
18
|
+
import { createPostgresRunEventLog } from "../adapters/postgres/run-event-log.js";
|
|
19
|
+
import { createPostgresRunStore } from "../adapters/postgres/run-store.js";
|
|
20
|
+
import { createMemoryEventBus } from "../runtime/streaming.js";
|
|
21
|
+
import { createDurableWorker } from "../runtime/worker.js";
|
|
22
|
+
import { createWorkerRuntime } from "../worker/main.js";
|
|
23
|
+
import { asId } from "../core/ids.js";
|
|
24
|
+
import { QUEUE_ATTEMPTS } from "../adapters/bullmq/dispatcher.js";
|
|
25
|
+
import { DEFAULT_TRAFFIC, createEffectLedger, createSyntheticEngine, runFate, seededRandom, } from "./scenario.js";
|
|
26
|
+
import { detectGrowth, summarizeLatency, summarizeThroughput } from "./metrics.js";
|
|
27
|
+
export const DEFAULT_HARNESS = {
|
|
28
|
+
workers: 3,
|
|
29
|
+
concurrency: 4,
|
|
30
|
+
leaseMs: 2_000,
|
|
31
|
+
reapEveryMs: 250,
|
|
32
|
+
traffic: DEFAULT_TRAFFIC,
|
|
33
|
+
seed: 20260823,
|
|
34
|
+
maxQueueDepth: 200,
|
|
35
|
+
sampleEveryMs: 250,
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* An in-process queue with a **bounded** depth and an explicit refusal.
|
|
39
|
+
*
|
|
40
|
+
* Used where the point is backpressure rather than Redis. Deliberately not BullMQ for the overload case: BullMQ's
|
|
41
|
+
* depth is Redis memory, so "bounded queueing" there is a Redis configuration question and the assertion would
|
|
42
|
+
* be about Redis. Here the bound is ours and the refusal is the platform's.
|
|
43
|
+
*/
|
|
44
|
+
export class QueueFull extends Error {
|
|
45
|
+
depth;
|
|
46
|
+
code = "resource-exhausted";
|
|
47
|
+
retryable = true;
|
|
48
|
+
constructor(depth) {
|
|
49
|
+
super(`queue full at depth ${depth}`);
|
|
50
|
+
this.depth = depth;
|
|
51
|
+
this.name = "QueueFull";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export const createBoundedQueue = (maxDepth, perWorkerConcurrency = 4) => {
|
|
55
|
+
/** A job and how many times it has been handed to a worker. */
|
|
56
|
+
const waiting = [];
|
|
57
|
+
let peak = 0;
|
|
58
|
+
let refused = 0;
|
|
59
|
+
let retried = 0;
|
|
60
|
+
/**
|
|
61
|
+
* One handler **per worker**, not one for the queue.
|
|
62
|
+
*
|
|
63
|
+
* The first version kept a single `handler` and every `start()` overwrote it — so three "competing" worker
|
|
64
|
+
* runtimes were one worker, and the claim race the load test exists to exercise was unreachable. The staircase
|
|
65
|
+
* reported ~6/s and looked plausible, which is why it survived: a wrong capacity number is indistinguishable
|
|
66
|
+
* from a real one without something to compare it against.
|
|
67
|
+
*/
|
|
68
|
+
const workers = [];
|
|
69
|
+
/**
|
|
70
|
+
* Every waiter, not one.
|
|
71
|
+
*
|
|
72
|
+
* A single `idle` slot meant a second concurrent `drained()` overwrote the first, and the first caller waited
|
|
73
|
+
* forever. `settle` and `runLoadStep` both call it, so this was a live hang waiting for a schedule that
|
|
74
|
+
* happened to overlap.
|
|
75
|
+
*/
|
|
76
|
+
const idleWaiters = [];
|
|
77
|
+
const busy = () => workers.reduce((n, w) => n + w.inFlight, 0);
|
|
78
|
+
const releaseIdle = () => {
|
|
79
|
+
// Drained and cleared in one go, so a waiter added while these are resolving is not resolved twice.
|
|
80
|
+
const waiters = idleWaiters.splice(0, idleWaiters.length);
|
|
81
|
+
for (const resolve of waiters)
|
|
82
|
+
resolve();
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Hand jobs to whichever worker has a free slot.
|
|
86
|
+
*
|
|
87
|
+
* Concurrent, not sequential. The first version awaited each handler inside a `while` loop, so the queue
|
|
88
|
+
* processed exactly one job at a time whatever the workers' configured concurrency was — the *queue* was the
|
|
89
|
+
* bottleneck the staircase was measuring, and it would have been published as the platform's capacity.
|
|
90
|
+
*/
|
|
91
|
+
const pump = () => {
|
|
92
|
+
for (;;) {
|
|
93
|
+
if (waiting.length === 0)
|
|
94
|
+
break;
|
|
95
|
+
const free = workers.find((w) => w.inFlight < perWorkerConcurrency);
|
|
96
|
+
if (free === undefined)
|
|
97
|
+
break;
|
|
98
|
+
const job = waiting.shift();
|
|
99
|
+
const attempts = (job.attempts ?? 0) + 1;
|
|
100
|
+
free.inFlight += 1;
|
|
101
|
+
void free
|
|
102
|
+
.handle(job)
|
|
103
|
+
.catch(() => {
|
|
104
|
+
/**
|
|
105
|
+
* Retry exactly as far as the real queue does — `QUEUE_ATTEMPTS`, which is currently **1**.
|
|
106
|
+
*
|
|
107
|
+
* Written against the constant rather than hard-coded, and that turned out to matter. I added this
|
|
108
|
+
* expecting BullMQ to retry and the harness to be the weaker one; `QUEUE_ATTEMPTS = 1` says otherwise.
|
|
109
|
+
* So the production queue does not retry a failed job either, and recovery rests entirely on the lease
|
|
110
|
+
* reaper — which only finds runs in `running` with an expired lease.
|
|
111
|
+
*
|
|
112
|
+
* That makes the database-unavailable result a **platform** finding, not a harness artifact: a run
|
|
113
|
+
* whose *claim* failed while the database was down stays `queued` with no job anywhere, and no
|
|
114
|
+
* mechanism will ever pick it up. See docs/16 — it is recorded rather than fixed here, because the
|
|
115
|
+
* queue's retry policy is a #105/#107 decision with consequences beyond this harness.
|
|
116
|
+
*
|
|
117
|
+
* The count is exposed, so a run that only passed *because* of retries is visible rather than looking
|
|
118
|
+
* like a clean first-attempt success.
|
|
119
|
+
*/
|
|
120
|
+
if (attempts < QUEUE_ATTEMPTS && workers.length > 0) {
|
|
121
|
+
retried += 1;
|
|
122
|
+
waiting.push({ ...job, attempts });
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
.finally(() => {
|
|
126
|
+
free.inFlight -= 1;
|
|
127
|
+
if (waiting.length > 0)
|
|
128
|
+
pump();
|
|
129
|
+
else if (busy() === 0)
|
|
130
|
+
releaseIdle();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
dispatcher: {
|
|
136
|
+
async enqueueRun(input) {
|
|
137
|
+
if (waiting.length >= maxDepth) {
|
|
138
|
+
refused += 1;
|
|
139
|
+
// Thrown, not dropped. AC-4's "honest refusal" is a typed error reaching the caller; a silent drop is
|
|
140
|
+
// data loss dressed as backpressure.
|
|
141
|
+
throw new QueueFull(waiting.length);
|
|
142
|
+
}
|
|
143
|
+
waiting.push({ tenantId: input.tenantId, runId: input.runId });
|
|
144
|
+
peak = Math.max(peak, waiting.length);
|
|
145
|
+
pump();
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
consumerFor() {
|
|
149
|
+
let slot = null;
|
|
150
|
+
return {
|
|
151
|
+
start(h) {
|
|
152
|
+
slot = { handle: h, inFlight: 0 };
|
|
153
|
+
workers.push(slot);
|
|
154
|
+
pump();
|
|
155
|
+
},
|
|
156
|
+
async stop() {
|
|
157
|
+
// Only *this* worker's slot. Its in-flight jobs are already running and will finish or fail on their
|
|
158
|
+
// own; removing the slot is "stop accepting new work", which is exactly what stop means.
|
|
159
|
+
const index = slot === null ? -1 : workers.indexOf(slot);
|
|
160
|
+
if (index >= 0)
|
|
161
|
+
workers.splice(index, 1);
|
|
162
|
+
slot = null;
|
|
163
|
+
// The remaining workers may now have room, and a queue that did not re-pump here would stall until the
|
|
164
|
+
// next enqueue — which during a drain is never.
|
|
165
|
+
pump();
|
|
166
|
+
if (waiting.length === 0 && busy() === 0)
|
|
167
|
+
releaseIdle();
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
depth: () => waiting.length,
|
|
172
|
+
peakDepth: () => peak,
|
|
173
|
+
refused: () => refused,
|
|
174
|
+
retried: () => retried,
|
|
175
|
+
/**
|
|
176
|
+
* Resolves when nothing more will be handed out and nothing is in flight.
|
|
177
|
+
*
|
|
178
|
+
* The `workers.length === 0` arm is not a shortcut — without it this **deadlocks**. A queue with jobs waiting
|
|
179
|
+
* and no worker to serve them can never drain, so a caller that stopped the workers and then awaited a drain
|
|
180
|
+
* waits forever. Returning lets the caller discover the runs are stuck, which is the fact it was trying to
|
|
181
|
+
* establish; blocking hides it behind a hang.
|
|
182
|
+
*/
|
|
183
|
+
drained: () => (waiting.length === 0 && busy() === 0) || (workers.length === 0 && busy() === 0)
|
|
184
|
+
? Promise.resolve()
|
|
185
|
+
: new Promise((resolve) => {
|
|
186
|
+
idleWaiters.push(resolve);
|
|
187
|
+
}),
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
const AGENT = asId("loadtest-agent");
|
|
191
|
+
/**
|
|
192
|
+
* Build the harness over an already-migrated schema.
|
|
193
|
+
*
|
|
194
|
+
* Takes a `SqlExecutor` rather than a URL so the caller owns the connection: the database-unavailable injector
|
|
195
|
+
* needs to stop the server underneath a live pool, and a harness that owned the pool would reconnect on its own
|
|
196
|
+
* schedule and the injector could not control the window.
|
|
197
|
+
*/
|
|
198
|
+
export const createHarness = async (input) => {
|
|
199
|
+
const { sql, config } = input;
|
|
200
|
+
const now = input.now ?? Date.now;
|
|
201
|
+
const runs = createPostgresRunStore(sql);
|
|
202
|
+
const checkpoints = createPostgresCheckpointStore(sql);
|
|
203
|
+
const eventLog = createPostgresRunEventLog(sql);
|
|
204
|
+
const { publisher } = createMemoryEventBus();
|
|
205
|
+
const effects = createEffectLedger();
|
|
206
|
+
const queue = createBoundedQueue(config.maxQueueDepth, config.concurrency);
|
|
207
|
+
const engine = createSyntheticEngine({
|
|
208
|
+
traffic: config.traffic,
|
|
209
|
+
effects,
|
|
210
|
+
random: seededRandom(config.seed),
|
|
211
|
+
now,
|
|
212
|
+
...(input.sleep !== undefined ? { sleep: input.sleep } : {}),
|
|
213
|
+
});
|
|
214
|
+
const buildContext = (run) => ({
|
|
215
|
+
tenantId: run.tenantId,
|
|
216
|
+
principalId: asId("loadtest-principal"),
|
|
217
|
+
roleIds: [],
|
|
218
|
+
locale: "en",
|
|
219
|
+
timezone: "UTC",
|
|
220
|
+
requestId: asId(`load-${run.id}`),
|
|
221
|
+
});
|
|
222
|
+
const handles = [];
|
|
223
|
+
const admittedIds = [];
|
|
224
|
+
for (let i = 0; i < config.workers; i += 1) {
|
|
225
|
+
const id = `w${i}`;
|
|
226
|
+
const worker = createDurableWorker({
|
|
227
|
+
runs,
|
|
228
|
+
checkpoints,
|
|
229
|
+
publisher,
|
|
230
|
+
engine,
|
|
231
|
+
eventLog,
|
|
232
|
+
buildContext,
|
|
233
|
+
workerId: id,
|
|
234
|
+
leaseMs: config.leaseMs,
|
|
235
|
+
now,
|
|
236
|
+
});
|
|
237
|
+
const runtime = createWorkerRuntime({
|
|
238
|
+
worker,
|
|
239
|
+
// Every runtime shares the one queue, so they genuinely compete for jobs and the atomic claim is exercised
|
|
240
|
+
// rather than assumed. A queue per worker would make the claim race unreachable, which is the single most
|
|
241
|
+
// important thing a load test of this platform can hit.
|
|
242
|
+
consumer: queue.consumerFor(),
|
|
243
|
+
dispatcher: queue.dispatcher,
|
|
244
|
+
config: {
|
|
245
|
+
concurrency: config.concurrency,
|
|
246
|
+
leaseMs: config.leaseMs,
|
|
247
|
+
reapEveryMs: config.reapEveryMs,
|
|
248
|
+
reapLimit: 50,
|
|
249
|
+
shutdownGraceMs: 1_000,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
await runtime.start();
|
|
253
|
+
handles.push({
|
|
254
|
+
id,
|
|
255
|
+
// A kill is `shutdown(0)` — stop consuming immediately and do not wait. The lease is left held, which is
|
|
256
|
+
// the situation the reaper exists for and the one a graceful shutdown would hide.
|
|
257
|
+
kill: async () => void (await runtime.shutdown("killed")),
|
|
258
|
+
shutdown: async () => void (await runtime.shutdown("drain")),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/** Hoisted so `settle` can call it: `this` inside the returned literal is typed as the enclosing promise. */
|
|
262
|
+
const approvePending = async () => {
|
|
263
|
+
// The paused runs, straight from the store. Re-enqueued rather than driven inline, so recovery goes through
|
|
264
|
+
// the same claim path a real approval decision takes -- an inline drive would test a path production never
|
|
265
|
+
// uses.
|
|
266
|
+
/**
|
|
267
|
+
* Bounded by the queue's free room, and that bound is not optional.
|
|
268
|
+
*
|
|
269
|
+
* The first version approved every waiting run at once. The queue is bounded, so most of those enqueues
|
|
270
|
+
* were **refused** — and because the transition had already happened, each refused run was left in
|
|
271
|
+
* `queued` with no job. `settle` then saw non-terminal runs, asked for `waiting-for-approval` runs, got
|
|
272
|
+
* none, and spun until its timeout. 1,293 runs orphaned this way in one soak.
|
|
273
|
+
*
|
|
274
|
+
* Which is the *same failure shape* as the platform bug this harness found an hour earlier: a run in
|
|
275
|
+
* `queued` that nothing will ever pick up. I introduced it in my own code by copying the fix's ordering
|
|
276
|
+
* without noticing that a bounded queue can refuse. Transition-then-enqueue is only safe when the enqueue
|
|
277
|
+
* cannot fail, so the batch is limited to the room actually available.
|
|
278
|
+
*/
|
|
279
|
+
const room = Math.max(0, config.maxQueueDepth - queue.depth());
|
|
280
|
+
if (room === 0)
|
|
281
|
+
return [];
|
|
282
|
+
const rows = await sql.query(
|
|
283
|
+
// `keepalive_at` when present, else `created_at`. There is no `updated_at` on `runs` -- and the wait a
|
|
284
|
+
// human actually imposes is measured from when the run stopped, which is its last keepalive.
|
|
285
|
+
`SELECT id, EXTRACT(EPOCH FROM (now() - COALESCE(keepalive_at, created_at))) * 1000 AS waited
|
|
286
|
+
FROM runs WHERE tenant_id = $1 AND status = 'waiting-for-approval'
|
|
287
|
+
ORDER BY created_at
|
|
288
|
+
LIMIT $2`, [config.tenantId, room]);
|
|
289
|
+
const waited = [];
|
|
290
|
+
for (const row of rows) {
|
|
291
|
+
const runId = asId(row.id);
|
|
292
|
+
try {
|
|
293
|
+
// Status *then* job, which is what `createApprovalService.decide` now does too. Enqueueing first lets a
|
|
294
|
+
// worker take the job while the run is still paused, fail the claim, and drop the only job that would
|
|
295
|
+
// have resumed it -- the bug this harness surfaced.
|
|
296
|
+
await runs.transition({ tenantId: config.tenantId, id: runId, workerId: "loadtest-approver", to: "queued", now: new Date(now()).toISOString() });
|
|
297
|
+
await queue.dispatcher.enqueueRun({ tenantId: config.tenantId, runId });
|
|
298
|
+
const ms = Number(row.waited);
|
|
299
|
+
if (Number.isFinite(ms))
|
|
300
|
+
waited.push(ms);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// A run that moved on between the read and the write. Not a failure of the approval; the next round
|
|
304
|
+
// picks it up. Swallowed so one bad row does not abandon the rest of the batch.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return waited;
|
|
308
|
+
};
|
|
309
|
+
return {
|
|
310
|
+
effects,
|
|
311
|
+
queue,
|
|
312
|
+
workers: handles,
|
|
313
|
+
approvePending,
|
|
314
|
+
async admit({ conversationId, runId }) {
|
|
315
|
+
await runs.create({ tenantId: config.tenantId, id: runId, conversationId, agentId: AGENT, agentVersion: 1 });
|
|
316
|
+
// Create first, then enqueue. The other order loses a run whose enqueue succeeded and whose row never
|
|
317
|
+
// existed — the queue would hand a worker an id it cannot find, and the work is gone with nothing to
|
|
318
|
+
// recover from.
|
|
319
|
+
try {
|
|
320
|
+
await queue.dispatcher.enqueueRun({ tenantId: config.tenantId, runId });
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
// A refused enqueue must not leave a `queued` row behind. The first version did, and the overload step
|
|
324
|
+
// then reported 236 refused *and* 236 stuck — the same runs counted twice, once correctly as refusals and
|
|
325
|
+
// once as lost work. A refused admission is not admitted work, so the row is cancelled and the caller
|
|
326
|
+
// still sees the refusal.
|
|
327
|
+
await runs
|
|
328
|
+
.transition({ tenantId: config.tenantId, id: runId, workerId: "loadtest-admit", to: "cancelled", now: new Date(now()).toISOString() })
|
|
329
|
+
.catch(() => undefined);
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
admittedIds.push(runId);
|
|
333
|
+
},
|
|
334
|
+
async terminalLatencies(prefix) {
|
|
335
|
+
// From the store's own timestamps, admission to terminal. The first version of this measured the latency of
|
|
336
|
+
// `admit()` — create plus enqueue — which is a few milliseconds however deep the backlog is. That made the
|
|
337
|
+
// envelope say "p99 5ms, sustainable" while the queue was 200 jobs deep and throughput had flatlined: the
|
|
338
|
+
// measurement flattered the system exactly where it was failing, which is worse than not measuring.
|
|
339
|
+
const rows = await sql.query(`SELECT id, EXTRACT(EPOCH FROM (finished_at - created_at)) * 1000 AS ms
|
|
340
|
+
FROM runs
|
|
341
|
+
WHERE tenant_id = $1 AND id LIKE $2 AND status = 'completed' AND finished_at IS NOT NULL`, [config.tenantId, `${prefix}%`]);
|
|
342
|
+
// Runs that paused for a human are **excluded**.
|
|
343
|
+
//
|
|
344
|
+
// Their end-to-end time is dominated by how fast the approver answered, which here is this harness's poll
|
|
345
|
+
// interval and in production is a person. Mixing them in put p99 at ~7 seconds at *every* step and made the
|
|
346
|
+
// envelope read "sustainable 0/s" — the platform's latency was 130ms and the number reported was the
|
|
347
|
+
// harness's own scheduling. Approval wait is measured separately, for the same reason #143 keeps it a
|
|
348
|
+
// distinct metric: it must be visibly not the platform's latency.
|
|
349
|
+
return rows
|
|
350
|
+
.filter((r) => runFate(r.id).b >= config.traffic.approvalRate)
|
|
351
|
+
.map((r) => Number(r.ms))
|
|
352
|
+
.filter((n) => Number.isFinite(n));
|
|
353
|
+
},
|
|
354
|
+
async terminalCount() {
|
|
355
|
+
// Counted from the store, not from the harness's own tally. The harness's tally is what it *believes*
|
|
356
|
+
// happened, and the entire question is whether the platform agrees.
|
|
357
|
+
const rows = await sql.query(`SELECT COUNT(*) AS n FROM runs
|
|
358
|
+
WHERE tenant_id = $1 AND status IN ('completed', 'failed', 'cancelled')`, [config.tenantId]);
|
|
359
|
+
return Number(rows[0]?.n ?? 0);
|
|
360
|
+
},
|
|
361
|
+
async statusCounts() {
|
|
362
|
+
const rows = await sql.query(`SELECT status, COUNT(*) AS n FROM runs WHERE tenant_id = $1 GROUP BY status`, [config.tenantId]);
|
|
363
|
+
return Object.fromEntries(rows.map((r) => [r.status, Number(r.n)]));
|
|
364
|
+
},
|
|
365
|
+
async settle({ idPrefix, timeoutMs }) {
|
|
366
|
+
const approvalWaitsMs = [];
|
|
367
|
+
const deadline = now() + timeoutMs;
|
|
368
|
+
const counts = async () => {
|
|
369
|
+
const rows = await sql.query(`SELECT status, COUNT(*) AS n FROM runs
|
|
370
|
+
WHERE tenant_id = $1 AND id LIKE $2 GROUP BY status`, [config.tenantId, `${idPrefix}%`]);
|
|
371
|
+
return Object.fromEntries(rows.map((r) => [r.status, Number(r.n)]));
|
|
372
|
+
};
|
|
373
|
+
for (;;) {
|
|
374
|
+
await queue.drained();
|
|
375
|
+
const byStatus = await counts();
|
|
376
|
+
const pending = (byStatus["queued"] ?? 0) + (byStatus["running"] ?? 0) + (byStatus["waiting-for-approval"] ?? 0) +
|
|
377
|
+
(byStatus["waiting-for-answer"] ?? 0) + (byStatus["retry-pending"] ?? 0);
|
|
378
|
+
if (pending === 0) {
|
|
379
|
+
return {
|
|
380
|
+
completed: byStatus["completed"] ?? 0,
|
|
381
|
+
failed: byStatus["failed"] ?? 0,
|
|
382
|
+
stuck: 0,
|
|
383
|
+
approvalWaitsMs,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
// Re-drive anything sitting in `queued` with no job. Belt as well as braces: the batch bound above should
|
|
387
|
+
// make this unnecessary, and it is here because an orphan in `queued` is invisible -- it looks exactly
|
|
388
|
+
// like a run waiting its turn, which is why the platform's version of this bug survived so long.
|
|
389
|
+
const orphans = await sql.query(`SELECT id FROM runs WHERE tenant_id = $1 AND id LIKE $2 AND status = 'queued' LIMIT $3`, [config.tenantId, `${idPrefix}%`, Math.max(0, config.maxQueueDepth - queue.depth())]);
|
|
390
|
+
for (const row of orphans)
|
|
391
|
+
await queue.dispatcher
|
|
392
|
+
.enqueueRun({ tenantId: config.tenantId, runId: asId(row.id) })
|
|
393
|
+
.catch(() => undefined);
|
|
394
|
+
if (now() >= deadline) {
|
|
395
|
+
const byStatusFinal = await counts();
|
|
396
|
+
return {
|
|
397
|
+
completed: byStatusFinal["completed"] ?? 0,
|
|
398
|
+
failed: byStatusFinal["failed"] ?? 0,
|
|
399
|
+
// Reported apart from `failed`, because they are different diagnoses: a failed run has an error and a
|
|
400
|
+
// stuck one has nothing, and an operator needs to know which they are looking at.
|
|
401
|
+
stuck: pending,
|
|
402
|
+
// *Which* statuses are stuck. A count alone sent me guessing between four different explanations;
|
|
403
|
+
// the breakdown answered it in one run.
|
|
404
|
+
stuckByStatus: Object.fromEntries(Object.entries(byStatusFinal).filter(([status]) => status !== "completed" && status !== "failed" && status !== "cancelled")),
|
|
405
|
+
approvalWaitsMs,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
// Anything waiting on a human is decided, then we go round again -- a resumed run can pause a second
|
|
409
|
+
// time on a later step, and a single pass would leave those pending forever.
|
|
410
|
+
approvalWaitsMs.push(...(await approvePending()));
|
|
411
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
admittedCount: () => admittedIds.length,
|
|
415
|
+
sample() {
|
|
416
|
+
const memory = process.memoryUsage();
|
|
417
|
+
return {
|
|
418
|
+
atMs: now(),
|
|
419
|
+
rssBytes: memory.rss,
|
|
420
|
+
heapUsedBytes: memory.heapUsed,
|
|
421
|
+
queueDepth: queue.depth(),
|
|
422
|
+
};
|
|
423
|
+
},
|
|
424
|
+
async stop() {
|
|
425
|
+
await Promise.all(handles.map((h) => h.shutdown()));
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
};
|
|
429
|
+
/**
|
|
430
|
+
* One step of the load staircase.
|
|
431
|
+
*
|
|
432
|
+
* Offers a fixed rate for a fixed duration, then waits for the queue to drain before measuring. Measuring
|
|
433
|
+
* without draining would attribute a step's backlog to the *next* step and the staircase would show capacity
|
|
434
|
+
* that is really borrowed from earlier.
|
|
435
|
+
*/
|
|
436
|
+
export const runLoadStep = async (input) => {
|
|
437
|
+
const now = input.now ?? Date.now;
|
|
438
|
+
const sleep = input.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
439
|
+
const gapMs = 1_000 / input.offeredPerSecond;
|
|
440
|
+
const samples = [];
|
|
441
|
+
let admitted = 0;
|
|
442
|
+
let failed = 0;
|
|
443
|
+
let refused = 0;
|
|
444
|
+
const admitFailures = [];
|
|
445
|
+
// A prefix unique to this step, so the store query reads this step's runs and not the whole staircase's. Without
|
|
446
|
+
// it, every step would report the cumulative latency distribution and each step would look better than the last.
|
|
447
|
+
const prefix = `load-s${input.offeredPerSecond}-`;
|
|
448
|
+
const startedAt = now();
|
|
449
|
+
let index = input.startIndex;
|
|
450
|
+
const inFlight = [];
|
|
451
|
+
while (now() - startedAt < input.durationMs) {
|
|
452
|
+
const runId = asId(`${prefix}${index}`);
|
|
453
|
+
const conversationId = asId(`load-c${index % 50}`);
|
|
454
|
+
index += 1;
|
|
455
|
+
inFlight.push(input.harness
|
|
456
|
+
.admit({ conversationId, runId })
|
|
457
|
+
.then(() => {
|
|
458
|
+
admitted += 1;
|
|
459
|
+
})
|
|
460
|
+
.catch((error) => {
|
|
461
|
+
// A refusal and a failure are counted apart, because they mean opposite things about the system: one is
|
|
462
|
+
// it working, the other is it not. Folding them together is how a correctly back-pressuring system
|
|
463
|
+
// gets "fixed" by removing the backpressure.
|
|
464
|
+
if (error?.code === "resource-exhausted")
|
|
465
|
+
refused += 1;
|
|
466
|
+
else {
|
|
467
|
+
failed += 1;
|
|
468
|
+
// The first *distinct* reason, capped. A step reporting "12 failed" with no reason sends whoever
|
|
469
|
+
// reads it back to reproduce the run; one reason line usually ends the investigation.
|
|
470
|
+
const reason = `${error?.code ?? "unknown"}: ${String(error).slice(0, 120)}`;
|
|
471
|
+
if (!admitFailures.includes(reason) && admitFailures.length < 5)
|
|
472
|
+
admitFailures.push(reason);
|
|
473
|
+
}
|
|
474
|
+
}));
|
|
475
|
+
samples.push(input.harness.sample());
|
|
476
|
+
await sleep(gapMs);
|
|
477
|
+
}
|
|
478
|
+
await Promise.allSettled(inFlight);
|
|
479
|
+
// Settle *before* measuring, and count the settling in the duration. Measuring at the end of the offer window
|
|
480
|
+
// instead would attribute this step's backlog to the next one, and the staircase would show capacity borrowed
|
|
481
|
+
// from earlier steps.
|
|
482
|
+
const settled = await input.harness.settle({ idPrefix: prefix, timeoutMs: input.settleTimeoutMs ?? 30_000 });
|
|
483
|
+
const latencies = await input.harness.terminalLatencies(prefix);
|
|
484
|
+
const terminalMs = now() - startedAt;
|
|
485
|
+
// `admitted` is a cross-check on the store, not a source: a mismatch means the harness and the platform
|
|
486
|
+
// disagree about what was accepted, which is worth knowing and is not the same as a failure.
|
|
487
|
+
void admitted;
|
|
488
|
+
return {
|
|
489
|
+
offeredPerSecond: input.offeredPerSecond,
|
|
490
|
+
latency: summarizeLatency(latencies),
|
|
491
|
+
// Completions from the **store's own status column**, not from what `admit()` resolved. A step that accepted
|
|
492
|
+
// 160 and finished 55 has not sustained 160/s, and only the store can say which number is which. A stuck run
|
|
493
|
+
// counts as a failure here — it did not complete, and the report keeps the two apart.
|
|
494
|
+
throughput: summarizeThroughput({
|
|
495
|
+
completed: settled.completed,
|
|
496
|
+
failed: failed + settled.failed + settled.stuck,
|
|
497
|
+
refused,
|
|
498
|
+
durationMs: terminalMs,
|
|
499
|
+
}),
|
|
500
|
+
peakRssBytes: Math.max(0, ...samples.map((s) => s.rssBytes)),
|
|
501
|
+
peakQueueDepth: Math.max(0, ...samples.map((s) => s.queueDepth ?? 0)),
|
|
502
|
+
...(admitFailures.length > 0 ? { admitFailures } : {}),
|
|
503
|
+
...(settled.stuck > 0 ? { stuck: settled.stuck, stuckByStatus: settled.stuckByStatus } : {}),
|
|
504
|
+
};
|
|
505
|
+
};
|
|
506
|
+
export { detectGrowth };
|
|
507
|
+
//# sourceMappingURL=harness.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load, soak and failure injection — REQ-033 (#144).
|
|
3
|
+
*
|
|
4
|
+
* Shipped rather than kept in `src/testing`, because an operator sizing a deployment needs to be able to run the
|
|
5
|
+
* same harness that produced the published envelope. Numbers measured on someone else's hardware are a starting
|
|
6
|
+
* point, not an answer.
|
|
7
|
+
*/
|
|
8
|
+
export * from "./metrics.js";
|
|
9
|
+
export * from "./injection.js";
|
|
10
|
+
export * from "./scenario.js";
|
|
11
|
+
export * from "./harness.js";
|
|
12
|
+
export * from "./runbooks.js";
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load, soak and failure injection — REQ-033 (#144).
|
|
3
|
+
*
|
|
4
|
+
* Shipped rather than kept in `src/testing`, because an operator sizing a deployment needs to be able to run the
|
|
5
|
+
* same harness that produced the published envelope. Numbers measured on someone else's hardware are a starting
|
|
6
|
+
* point, not an answer.
|
|
7
|
+
*/
|
|
8
|
+
export * from "./metrics.js";
|
|
9
|
+
export * from "./injection.js";
|
|
10
|
+
export * from "./scenario.js";
|
|
11
|
+
export * from "./harness.js";
|
|
12
|
+
export * from "./runbooks.js";
|
|
13
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The failure-injection matrix — AC-3, AC-5.
|
|
3
|
+
*
|
|
4
|
+
* Declared as **data** rather than as a list of test functions, for three reasons that all reduce to the same
|
|
5
|
+
* one: a failure mode that is not in a machine-readable list is a failure mode whose runbook can go missing
|
|
6
|
+
* without anything noticing.
|
|
7
|
+
*
|
|
8
|
+
* - A test asserts that every mode here has a runbook, so a new injector cannot ship undocumented (AC-5).
|
|
9
|
+
* - Each mode carries what recovery *means* for it, so "recovered" is not a judgement call made per run.
|
|
10
|
+
* - `requiresInfrastructure` marks the ones that need something this package cannot start, which is how the
|
|
11
|
+
* honest gap stays visible instead of becoming a quietly skipped case.
|
|
12
|
+
*/
|
|
13
|
+
export declare const FAILURE_MODES: readonly ["provider-timeout", "provider-rate-limit", "redis-unavailable", "database-unavailable", "database-failover", "worker-kill", "slow-consumer", "overload"];
|
|
14
|
+
export type FailureMode = (typeof FAILURE_MODES)[number];
|
|
15
|
+
export type RecoveryExpectation = {
|
|
16
|
+
/** Work must reach a terminal state, eventually, without a human touching it. */
|
|
17
|
+
readonly recoversUnattended: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* No lost work: every admitted run reaches a terminal state.
|
|
20
|
+
*
|
|
21
|
+
* `false` only where losing work is the *correct* behaviour, which is nowhere in this list — a refused
|
|
22
|
+
* admission is not admitted work.
|
|
23
|
+
*/
|
|
24
|
+
readonly noDataLoss: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* No duplicated external action.
|
|
27
|
+
*
|
|
28
|
+
* The assertion that actually matters, and the one a load test is uniquely able to make: a retry after a
|
|
29
|
+
* worker kill must not publish the post twice. Measured by counting effects against distinct idempotency
|
|
30
|
+
* keys, not by inspecting logs.
|
|
31
|
+
*/
|
|
32
|
+
readonly noDuplicateExternalAction: boolean;
|
|
33
|
+
/** What the operator sees while it is happening. Goes straight into the runbook. */
|
|
34
|
+
readonly observableSymptom: string;
|
|
35
|
+
/** The mechanism that does the recovering, named so a runbook can point at it. */
|
|
36
|
+
readonly mechanism: string;
|
|
37
|
+
};
|
|
38
|
+
export type FailureModeSpec = {
|
|
39
|
+
readonly mode: FailureMode;
|
|
40
|
+
readonly description: string;
|
|
41
|
+
readonly expect: RecoveryExpectation;
|
|
42
|
+
/**
|
|
43
|
+
* Whether the injector needs infrastructure this package cannot create.
|
|
44
|
+
*
|
|
45
|
+
* `true` for the modes that need a container stopped or a cluster failed over. Named rather than skipped: a
|
|
46
|
+
* case that quietly does not run reports as covered, which is exactly how #20 closed green against "passes the
|
|
47
|
+
* full conformance suite".
|
|
48
|
+
*/
|
|
49
|
+
readonly requiresInfrastructure: boolean;
|
|
50
|
+
};
|
|
51
|
+
export declare const FAILURE_MATRIX: Readonly<Record<FailureMode, FailureModeSpec>>;
|
|
52
|
+
/** The modes this package can inject on its own. The rest need a container or a cluster. */
|
|
53
|
+
export declare const SELF_CONTAINED_MODES: readonly FailureMode[];
|
|
54
|
+
export type InjectionResult = {
|
|
55
|
+
readonly mode: FailureMode;
|
|
56
|
+
readonly injected: boolean;
|
|
57
|
+
/** Every admitted run reached a terminal state. */
|
|
58
|
+
readonly admitted: number;
|
|
59
|
+
readonly terminal: number;
|
|
60
|
+
/** External effects performed, and the distinct idempotency keys they were performed under. */
|
|
61
|
+
readonly externalEffects: number;
|
|
62
|
+
readonly distinctEffectKeys: number;
|
|
63
|
+
readonly recoveredMs: number;
|
|
64
|
+
/**
|
|
65
|
+
* How many runs a human had to touch.
|
|
66
|
+
*
|
|
67
|
+
* Non-zero means recovery was **not** unattended, whatever else went right. Added because the
|
|
68
|
+
* database-unavailable injection passed with a green tick while every one of its forty runs had been re-driven
|
|
69
|
+
* by the harness itself — no work was lost and nothing was duplicated, both true, and "recovers unattended"
|
|
70
|
+
* was not demonstrated at all. A verdict that cannot tell those apart is the kind of green tick this whole
|
|
71
|
+
* exercise exists to stop.
|
|
72
|
+
*/
|
|
73
|
+
readonly manualInterventions: number;
|
|
74
|
+
readonly notes: readonly string[];
|
|
75
|
+
};
|
|
76
|
+
export type InjectionVerdict = {
|
|
77
|
+
readonly mode: FailureMode;
|
|
78
|
+
readonly passed: boolean;
|
|
79
|
+
readonly failures: readonly string[];
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Judge a result against the mode's declared expectation.
|
|
83
|
+
*
|
|
84
|
+
* Separate from running it, so the judgement is testable against a *fabricated* result — including the results
|
|
85
|
+
* that must fail. A verdict function only ever exercised by passing runs is one that might return "passed" for
|
|
86
|
+
* everything, and nothing would reveal that.
|
|
87
|
+
*/
|
|
88
|
+
export declare const judgeInjection: (result: InjectionResult) => InjectionVerdict;
|
|
89
|
+
//# sourceMappingURL=injection.d.ts.map
|