bunsane 0.3.2 → 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/CHANGELOG.md +445 -370
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +85 -1020
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +94 -85
- package/core/RequestLoaders.ts +98 -5
- package/core/SchedulerManager.ts +28 -600
- package/core/app/cors.ts +2 -11
- package/core/app/preparedStatementWarmup.ts +9 -49
- package/core/app/requestRouter.ts +9 -8
- package/core/app/restRegistry.ts +8 -0
- package/core/archetype/fieldResolvers.ts +85 -40
- package/core/archetype/relationLoader.ts +135 -92
- package/core/cache/CacheManager.ts +91 -302
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +4 -4
- package/core/cache/health.ts +30 -0
- package/core/cache/invalidation.ts +96 -0
- package/core/cache/strategies/writeInvalidate.ts +111 -0
- package/core/cache/strategies/writeThrough.ts +233 -0
- package/core/components/BaseComponent.ts +16 -8
- package/core/components/ComponentRegistry.ts +28 -0
- package/core/decorators/IndexedField.ts +1 -1
- package/core/entity/cacheStrategies.ts +97 -0
- package/core/entity/componentAccess.ts +364 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +377 -0
- package/core/hooks/dispatcher.ts +439 -0
- package/core/hooks/guards.ts +155 -0
- package/core/hooks/registry.ts +247 -0
- package/core/metadata/definitions/Component.ts +1 -1
- package/core/metadata/index.ts +15 -4
- package/core/middleware/RateLimit.ts +102 -105
- package/core/middleware/RequestId.ts +2 -9
- package/core/middleware/SecurityHeaders.ts +2 -11
- package/core/middleware/headers.ts +28 -0
- package/core/remote/OutboxWorker.ts +213 -183
- package/core/remote/RemoteManager.ts +401 -400
- package/core/remote/types.ts +153 -151
- package/core/requestScope.ts +34 -0
- package/core/scheduler/cronEvaluator.ts +174 -0
- package/core/scheduler/lifecycleHooks.ts +21 -0
- package/core/scheduler/lockCoordinator.ts +27 -0
- package/core/scheduler/metrics.ts +14 -0
- package/core/scheduler/taskRunner.ts +420 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +8 -2
- package/database/cancellable.ts +35 -22
- package/database/index.ts +15 -3
- package/database/instrumentedDb.ts +141 -141
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +240 -13
- package/query/OrNode.ts +6 -5
- package/query/Query.ts +157 -46
- package/query/QueryContext.ts +6 -0
- package/query/QueryDAG.ts +7 -2
- package/query/membershipSource.ts +66 -0
- package/storage/LocalStorageProvider.ts +8 -3
- package/studio/dist/assets/index-BMZ67Npg.js +254 -0
- package/studio/dist/assets/index-BpbuYz9g.css +1 -0
- package/studio/{index.html → dist/index.html} +3 -2
- package/swagger/generator.ts +11 -1
- package/upload/UploadManager.ts +8 -6
- package/utils/uuid.ts +40 -10
- package/.claude/scheduled_tasks.lock +0 -1
- package/.claude/settings.local.json +0 -47
- package/.prettierrc +0 -4
- package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
- package/.serena/memories/architecture.md +0 -154
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
- package/.serena/memories/code_style_and_conventions.md +0 -76
- package/.serena/memories/project_overview.md +0 -43
- package/.serena/memories/schema-dsl-plan.md +0 -107
- package/.serena/memories/suggested_commands.md +0 -80
- package/.serena/memories/typescript-compilation-status.md +0 -54
- package/.serena/project.yml +0 -114
- package/BunSane.jpg +0 -0
- package/CLAUDE.md +0 -198
- package/TODO.md +0 -2
- package/bun.lock +0 -302
- package/bunfig.toml +0 -10
- package/docs/RFC_APP_REFACTOR.md +0 -248
- package/docs/RFC_REFACTOR_TARGETS.md +0 -251
- package/docs/SCALABILITY_PLAN.md +0 -175
- package/studio/bun.lock +0 -482
- package/studio/package.json +0 -39
- package/studio/postcss.config.js +0 -6
- package/studio/src/components/DataTable.tsx +0 -211
- package/studio/src/components/Layout.tsx +0 -13
- package/studio/src/components/PageContainer.tsx +0 -9
- package/studio/src/components/PageHeader.tsx +0 -13
- package/studio/src/components/SearchBar.tsx +0 -57
- package/studio/src/components/Sidebar.tsx +0 -294
- package/studio/src/components/ui/button.tsx +0 -56
- package/studio/src/components/ui/checkbox.tsx +0 -26
- package/studio/src/components/ui/input.tsx +0 -25
- package/studio/src/hooks/useDataTable.ts +0 -131
- package/studio/src/index.css +0 -36
- package/studio/src/lib/api.ts +0 -186
- package/studio/src/lib/utils.ts +0 -13
- package/studio/src/main.tsx +0 -17
- package/studio/src/pages/ArcheType.tsx +0 -239
- package/studio/src/pages/Components.tsx +0 -124
- package/studio/src/pages/EntityInspector.tsx +0 -302
- package/studio/src/pages/QueryRunner.tsx +0 -246
- package/studio/src/pages/Table.tsx +0 -94
- package/studio/src/pages/Welcome.tsx +0 -241
- package/studio/src/routes.tsx +0 -45
- package/studio/src/store/archeTypeSettings.ts +0 -30
- package/studio/src/store/studio.ts +0 -65
- package/studio/src/utils/columnHelpers.tsx +0 -114
- package/studio/studio-instructions.md +0 -81
- package/studio/tailwind.config.js +0 -77
- package/studio/utils.ts +0 -54
- package/studio/vite.config.js +0 -19
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
- package/tests/benchmark/bunfig.toml +0 -9
- package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
- package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
- package/tests/benchmark/fixtures/index.ts +0 -6
- package/tests/benchmark/index.ts +0 -22
- package/tests/benchmark/noop-preload.ts +0 -3
- package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
- package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
- package/tests/benchmark/runners/index.ts +0 -4
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
- package/tests/benchmark/scripts/generate-db.ts +0 -344
- package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
- package/tests/e2e/http.test.ts +0 -130
- package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
- package/tests/fixtures/components/TestOrder.ts +0 -23
- package/tests/fixtures/components/TestProduct.ts +0 -23
- package/tests/fixtures/components/TestUser.ts +0 -20
- package/tests/fixtures/components/index.ts +0 -6
- package/tests/graphql/SchemaGeneration.test.ts +0 -90
- package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
- package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
- package/tests/helpers/MockRedisClient.ts +0 -113
- package/tests/helpers/MockRedisStreamServer.ts +0 -448
- package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
- package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
- package/tests/integration/entity/Entity.persistence.test.ts +0 -333
- package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
- package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
- package/tests/integration/query/Query.abort.test.ts +0 -66
- package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
- package/tests/integration/query/Query.edgeCases.test.ts +0 -595
- package/tests/integration/query/Query.exec.test.ts +0 -576
- package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
- package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
- package/tests/integration/remote/dlq.test.ts +0 -175
- package/tests/integration/remote/event-dispatch.test.ts +0 -114
- package/tests/integration/remote/outbox.test.ts +0 -130
- package/tests/integration/remote/rpc.test.ts +0 -177
- package/tests/pglite-setup.ts +0 -62
- package/tests/setup.ts +0 -164
- package/tests/stress/BenchmarkRunner.ts +0 -203
- package/tests/stress/DataSeeder.ts +0 -190
- package/tests/stress/StressTestReporter.ts +0 -229
- package/tests/stress/cursor-perf-test.ts +0 -171
- package/tests/stress/fixtures/RealisticComponents.ts +0 -235
- package/tests/stress/fixtures/StressTestComponents.ts +0 -58
- package/tests/stress/index.ts +0 -7
- package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
- package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
- package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
- package/tests/unit/BatchLoader.test.ts +0 -196
- package/tests/unit/archetype/ArcheType.test.ts +0 -107
- package/tests/unit/cache/CacheManager.test.ts +0 -498
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/database/cancellable.test.ts +0 -81
- package/tests/unit/database/instrumentedDb.test.ts +0 -160
- package/tests/unit/entity/Entity.components.test.ts +0 -317
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
- package/tests/unit/entity/Entity.reload.test.ts +0 -63
- package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
- package/tests/unit/entity/Entity.test.ts +0 -345
- package/tests/unit/gql/depthLimit.test.ts +0 -203
- package/tests/unit/gql/operationMiddleware.test.ts +0 -293
- package/tests/unit/health/Health.test.ts +0 -129
- package/tests/unit/middleware/AccessLog.test.ts +0 -37
- package/tests/unit/middleware/Middleware.test.ts +0 -98
- package/tests/unit/middleware/RequestId.test.ts +0 -54
- package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
- package/tests/unit/query/FilterBuilder.test.ts +0 -111
- package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
- package/tests/unit/query/Query.emptyString.test.ts +0 -69
- package/tests/unit/query/Query.test.ts +0 -310
- package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
- package/tests/unit/remote/RemoteError.test.ts +0 -55
- package/tests/unit/remote/decorators.test.ts +0 -195
- package/tests/unit/remote/metrics.test.ts +0 -115
- package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
- package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
- package/tests/unit/schema/schema-integration.test.ts +0 -426
- package/tests/unit/schema/schema.test.ts +0 -580
- package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
- package/tests/unit/upload/RestUpload.test.ts +0 -267
- package/tests/unit/validateEnv.test.ts +0 -82
- package/tests/utils/entity-tracker.ts +0 -57
- package/tests/utils/index.ts +0 -13
- package/tests/utils/test-context.ts +0 -149
|
@@ -1,183 +1,213 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Remote Communication: OutboxWorker
|
|
3
|
-
*
|
|
4
|
-
* Polls `remote_outbox` for unpublished rows, publishes each to Redis, and
|
|
5
|
-
* marks the row published. Uses `FOR UPDATE SKIP LOCKED` so multiple
|
|
6
|
-
* instances can run workers concurrently without double-publishing:
|
|
7
|
-
* each row is claimed by exactly one worker per batch.
|
|
8
|
-
*
|
|
9
|
-
* At-least-once semantics: if the worker crashes after XADD but before the
|
|
10
|
-
* UPDATE commits, the row stays pending and will be republished. Consumers
|
|
11
|
-
* must be idempotent — enforce this at the handler level (e.g., dedup on
|
|
12
|
-
* `ctx.messageId` or domain-level idempotency keys).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type Redis from "ioredis";
|
|
16
|
-
import { sql as sqlHelper, type SQL } from "bun";
|
|
17
|
-
import { logger } from "../Logger";
|
|
18
|
-
import type { RemoteMetrics } from "./metrics";
|
|
19
|
-
|
|
20
|
-
const loggerInstance = logger.child({ scope: "OutboxWorker" });
|
|
21
|
-
|
|
22
|
-
export interface OutboxWorkerConfig {
|
|
23
|
-
sourceApp: string;
|
|
24
|
-
streamPrefix: string;
|
|
25
|
-
pollIntervalMs: number;
|
|
26
|
-
batchSize: number;
|
|
27
|
-
enableLogging: boolean;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
private
|
|
42
|
-
private
|
|
43
|
-
private
|
|
44
|
-
private
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Remote Communication: OutboxWorker
|
|
3
|
+
*
|
|
4
|
+
* Polls `remote_outbox` for unpublished rows, publishes each to Redis, and
|
|
5
|
+
* marks the row published. Uses `FOR UPDATE SKIP LOCKED` so multiple
|
|
6
|
+
* instances can run workers concurrently without double-publishing:
|
|
7
|
+
* each row is claimed by exactly one worker per batch.
|
|
8
|
+
*
|
|
9
|
+
* At-least-once semantics: if the worker crashes after XADD but before the
|
|
10
|
+
* UPDATE commits, the row stays pending and will be republished. Consumers
|
|
11
|
+
* must be idempotent — enforce this at the handler level (e.g., dedup on
|
|
12
|
+
* `ctx.messageId` or domain-level idempotency keys).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type Redis from "ioredis";
|
|
16
|
+
import { sql as sqlHelper, type SQL } from "bun";
|
|
17
|
+
import { logger } from "../Logger";
|
|
18
|
+
import type { RemoteMetrics } from "./metrics";
|
|
19
|
+
|
|
20
|
+
const loggerInstance = logger.child({ scope: "OutboxWorker" });
|
|
21
|
+
|
|
22
|
+
export interface OutboxWorkerConfig {
|
|
23
|
+
sourceApp: string;
|
|
24
|
+
streamPrefix: string;
|
|
25
|
+
pollIntervalMs: number;
|
|
26
|
+
batchSize: number;
|
|
27
|
+
enableLogging: boolean;
|
|
28
|
+
/** Retention window for published rows in ms. 0 disables trimming. Default 24h. */
|
|
29
|
+
retentionMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface OutboxRow {
|
|
33
|
+
id: string;
|
|
34
|
+
target: string;
|
|
35
|
+
event: string;
|
|
36
|
+
data: unknown;
|
|
37
|
+
created_at: Date;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class OutboxWorker {
|
|
41
|
+
private db: SQL;
|
|
42
|
+
private publisher: Redis;
|
|
43
|
+
private config: OutboxWorkerConfig;
|
|
44
|
+
private running = false;
|
|
45
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
46
|
+
private currentTick: Promise<void> | null = null;
|
|
47
|
+
private metrics?: RemoteMetrics;
|
|
48
|
+
private lastTrimAt = 0;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
db: SQL,
|
|
52
|
+
publisher: Redis,
|
|
53
|
+
config: OutboxWorkerConfig,
|
|
54
|
+
metrics?: RemoteMetrics
|
|
55
|
+
) {
|
|
56
|
+
this.db = db;
|
|
57
|
+
this.publisher = publisher;
|
|
58
|
+
this.config = config;
|
|
59
|
+
this.metrics = metrics;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async start(): Promise<void> {
|
|
63
|
+
if (this.running) return;
|
|
64
|
+
this.running = true;
|
|
65
|
+
this.scheduleNext(0);
|
|
66
|
+
loggerInstance.info(
|
|
67
|
+
`OutboxWorker started pollMs=${this.config.pollIntervalMs} batch=${this.config.batchSize}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async stop(): Promise<void> {
|
|
72
|
+
if (!this.running) return;
|
|
73
|
+
this.running = false;
|
|
74
|
+
if (this.timer) {
|
|
75
|
+
clearTimeout(this.timer);
|
|
76
|
+
this.timer = null;
|
|
77
|
+
}
|
|
78
|
+
if (this.currentTick) {
|
|
79
|
+
await this.currentTick.catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
loggerInstance.info("OutboxWorker stopped");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Force an immediate tick. Used during shutdown to flush any
|
|
86
|
+
* committed-but-unpublished rows before the process exits.
|
|
87
|
+
*/
|
|
88
|
+
async flush(): Promise<void> {
|
|
89
|
+
await this.tick();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private scheduleNext(delayMs: number): void {
|
|
93
|
+
if (!this.running) return;
|
|
94
|
+
this.timer = setTimeout(() => {
|
|
95
|
+
this.currentTick = this.tick().finally(() => {
|
|
96
|
+
this.currentTick = null;
|
|
97
|
+
this.scheduleNext(this.config.pollIntervalMs);
|
|
98
|
+
});
|
|
99
|
+
}, delayMs);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async tick(): Promise<void> {
|
|
103
|
+
if (!this.running) return;
|
|
104
|
+
try {
|
|
105
|
+
await this.processBatch();
|
|
106
|
+
await this.maybeTrimPublished();
|
|
107
|
+
} catch (error: any) {
|
|
108
|
+
loggerInstance.error(
|
|
109
|
+
{ err: error, msg: "OutboxWorker tick error" }
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async maybeTrimPublished(): Promise<void> {
|
|
115
|
+
const { retentionMs } = this.config;
|
|
116
|
+
if (!retentionMs) return;
|
|
117
|
+
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
// At most once per hour to avoid frequent lock contention
|
|
120
|
+
if (now - this.lastTrimAt < 3_600_000) return;
|
|
121
|
+
this.lastTrimAt = now;
|
|
122
|
+
|
|
123
|
+
const cutoff = new Date(now - retentionMs);
|
|
124
|
+
const db = this.db as any;
|
|
125
|
+
const result = await db`
|
|
126
|
+
DELETE FROM remote_outbox
|
|
127
|
+
WHERE id IN (
|
|
128
|
+
SELECT id FROM remote_outbox
|
|
129
|
+
WHERE published_at IS NOT NULL
|
|
130
|
+
AND published_at < ${cutoff}
|
|
131
|
+
LIMIT 10000
|
|
132
|
+
)
|
|
133
|
+
`;
|
|
134
|
+
const count = result.count ?? result.length ?? 0;
|
|
135
|
+
if (count > 0) {
|
|
136
|
+
loggerInstance.debug(`Trimmed ${count} published outbox rows older than ${cutoff.toISOString()}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async processBatch(): Promise<void> {
|
|
141
|
+
const db = this.db as any;
|
|
142
|
+
await db.begin(async (trx: any) => {
|
|
143
|
+
const rows: OutboxRow[] = await trx`
|
|
144
|
+
SELECT id, target, event, data, created_at
|
|
145
|
+
FROM remote_outbox
|
|
146
|
+
WHERE published_at IS NULL
|
|
147
|
+
ORDER BY created_at
|
|
148
|
+
LIMIT ${this.config.batchSize}
|
|
149
|
+
FOR UPDATE SKIP LOCKED
|
|
150
|
+
`;
|
|
151
|
+
|
|
152
|
+
if (rows.length === 0) return;
|
|
153
|
+
|
|
154
|
+
this.metrics?.outboxClaimed(rows.length);
|
|
155
|
+
if (this.config.enableLogging) {
|
|
156
|
+
loggerInstance.debug(`Claimed ${rows.length} outbox rows`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Publish concurrently rather than serially. Each xadd is bounded
|
|
160
|
+
// by the publisher client's `commandTimeout`; with serial awaits a
|
|
161
|
+
// batch of N slow rows would hold PG row locks for N × timeout.
|
|
162
|
+
// Parallel keeps worst-case lock hold ≈ single-xadd timeout.
|
|
163
|
+
// (H-DB-1 partial — full fix requires a claim-via-column design
|
|
164
|
+
// so Redis latency no longer sits inside a PG transaction at all.)
|
|
165
|
+
const publishResults = await Promise.allSettled(
|
|
166
|
+
rows.map((row) => {
|
|
167
|
+
const stream = `${this.config.streamPrefix}${row.target}`;
|
|
168
|
+
const envelope = JSON.stringify({
|
|
169
|
+
kind: "event",
|
|
170
|
+
sourceApp: this.config.sourceApp,
|
|
171
|
+
event: row.event,
|
|
172
|
+
data: row.data,
|
|
173
|
+
emittedAt: row.created_at.getTime(),
|
|
174
|
+
});
|
|
175
|
+
return this.publisher.xadd(stream, "*", "data", envelope);
|
|
176
|
+
})
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const successIds: string[] = [];
|
|
180
|
+
for (let i = 0; i < publishResults.length; i++) {
|
|
181
|
+
const r = publishResults[i];
|
|
182
|
+
const row = rows[i]!;
|
|
183
|
+
if (r!.status === "fulfilled") {
|
|
184
|
+
successIds.push(row.id);
|
|
185
|
+
} else {
|
|
186
|
+
this.metrics?.outboxPublishFailed();
|
|
187
|
+
loggerInstance.error({
|
|
188
|
+
err: r!.reason,
|
|
189
|
+
outboxId: row.id,
|
|
190
|
+
target: row.target,
|
|
191
|
+
event: row.event,
|
|
192
|
+
msg: "Outbox XADD failed — row will retry next tick",
|
|
193
|
+
});
|
|
194
|
+
// Leave row unpublished; SKIP LOCKED releases on tx end
|
|
195
|
+
// so next tick (or another instance) picks it up.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (successIds.length > 0) {
|
|
200
|
+
// Single bulk UPDATE instead of N round-trips holding row
|
|
201
|
+
// locks (H-DB-3). Previously each success fired its own
|
|
202
|
+
// UPDATE statement serially. Uses Bun SQL's `sql(...)` helper
|
|
203
|
+
// for the IN-list so ids are parameterised individually.
|
|
204
|
+
await trx`
|
|
205
|
+
UPDATE remote_outbox
|
|
206
|
+
SET published_at = NOW()
|
|
207
|
+
WHERE id IN ${sqlHelper(successIds)}
|
|
208
|
+
`;
|
|
209
|
+
this.metrics?.outboxPublished(successIds.length);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|