Skip to content

Commit f37d29d

Browse files
light weight scheduler and outbox sweeper (part 1)
1 parent 113234b commit f37d29d

49 files changed

Lines changed: 437 additions & 255 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ dev-web:
5757

5858
dev-worker-orchestrator:
5959
@echo "Starting Orchestrator Worker for local development..."
60-
ENVIRONMENT=development PYTHONPATH=services/workers/orchestrator/src:libs/database/src:libs/config/src:libs/pipeline/src:libs/domain/src:libs/transformer/src uv run python services/workers/orchestrator/src/worker/main.py
60+
ENVIRONMENT=development PYTHONPATH=services/workers/orchestrator/src:libs/database/src:libs/config/src:libs/pipeline/src:libs/domain/src:libs/transformer/src:libs/scheduler/src uv run python services/workers/orchestrator/src/worker/main.py
6161

6262
dev-worker-compute:
6363
@echo "Starting Compute Worker for local development..."
@@ -92,7 +92,7 @@ db-sqs-reset: db-reset sqs-purge
9292

9393
clear-data:
9494
@echo "Clearing data plane tables (edi_message, edi_json, api_gateway, outbox) and purging SQS..."
95-
uv run python scripts/clear_data.py
95+
uv run python scripts/clear_data.py --i-am-sure
9696

9797
seed: db-init
9898

TECHNICAL_DEBT.md

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,17 @@ To achieve the long-term vision of completely merging and modernizing `bots_core
2222
## 2. Outbox Sweeper (CDC Fallback Relay)
2323

2424
**Description:**
25-
The system currently relies exclusively on Debezium (CDC) reading the PostgreSQL Write-Ahead Log (WAL) to route `Outbox` events to SQS. If Debezium crashes, loses offsets, or experiences network partitioning, `PENDING` outbox events will be permanently trapped in the database, breaking the asynchronous event pipeline.
25+
The Outbox Sweeper has been implemented as a fallback to Debezium (CDC). It iterates over all shards to relay `PENDING` outbox events to SQS. However, the current implementation iterates over shards sequentially (`for shard in shards: await self._sweep_shard(...)`). As the number of shards grows in the multi-tenant architecture, this sequential sweep will take longer and potentially exceed the polling interval, causing lag.
2626

2727
**Proposed Resolution:**
28-
Implement an Outbox Sweeper background worker that acts as a robust enterprise fallback and garbage collector:
29-
1. **Fallback Poller:** A cron/scheduled task that periodically queries `SELECT * FROM outbox WHERE status = 'PENDING'` for events older than a configured threshold (e.g., 60 seconds) and manually relays them to SQS.
30-
2. **Garbage Collector:** A cleanup task that runs `DELETE FROM outbox WHERE status = 'COMPLETED'` for events older than 7 days to prevent unbounded database growth.
28+
Refactor the Outbox Sweeper to use Bounded Concurrency or Distributed Job Fan-out:
29+
1. **Bounded Concurrency:** Run sweeps concurrently using `asyncio.gather` bounded by an `asyncio.Semaphore` so multiple shards are swept at once without exhausting resources.
30+
2. **Distributed Job Fan-out:** Instead of a single job, spawn a `ScheduledJob` for each shard dynamically, allowing multiple orchestrator pods to load balance the shard sweeping.
31+
3. **Garbage Collector (Pending):** A cleanup task that runs `DELETE FROM outbox WHERE status = 'COMPLETED'` for events older than 7 days to prevent unbounded database growth is still needed.
3132

32-
**Estimated Effort:** Low
33+
**Estimated Effort:** Low-Medium
3334
**Estimated Time:** 1 to 2 days
34-
**Impact:** Essential for enterprise-grade high availability. Guarantees no messages are ever lost due to CDC infrastructure failures and keeps the database optimized over time.
35+
**Impact:** Prevents the sweeper from falling behind as the number of database shards scales, ensuring enterprise-grade multi-tenant reliability.
3536

3637
## 3. AS2 Protocol
3738

@@ -80,3 +81,19 @@ Currently, the bots engine does not support a lightweight validation mode (e.g.,
8081
### UnitOfWork Architecture (Control Plane vs Data Plane Naming)
8182
Currently, the `UnitOfWork` (and its underlying SQL Alchemy repositories) leak infrastructure/deployment boundaries ("Control Plane" and "Data Plane") into domain business logic. We have giant God-objects like `SqlAlchemyControlPlaneRepository` inheriting from 10+ distinct repositories, causing namespace collisions and violating SOLID principles (Single Responsibility Principle).
8283
**Future Action:** Refactor `UnitOfWork` to remove `control_plane` and `data_plane` concepts from class names and properties. Use Composition to expose distinct Bounded Contexts (e.g., `self.trading_partners`, `self.transactions`, `self.routes`) instead of lumping them into control/data plane buckets.
84+
85+
## 8. Hybrid SQS Tenancy (Dynamic Queue Resolution)
86+
87+
**Description:**
88+
Currently, both inbound and outbound events are routed to a static, shared SQS queue (e.g., `TransformOrchestrationQueue`). In a multi-tenant environment, a massive batch of outbound events from one tenant can block critical inbound processing for all other tenants (the "noisy neighbor" problem).
89+
90+
**Proposed Resolution:**
91+
Implement a Hybrid SQS routing model that dynamically resolves the target queue based on the tenant's tier and the event direction:
92+
1. **Dynamic Queue Resolver:** The CDC Relay and Outbox Sweeper should read the `tenant_id` from the outbox event and lookup the tenant's tier (cached in memory).
93+
2. **Standard Tenants:** Route inbound events to `standard-inbound-queue` and outbound events to `standard-outbound-queue`.
94+
3. **Enterprise Tenants:** Route events to dedicated queues (e.g., `enterprise-{tenant_name}-inbound-queue`).
95+
4. **Dedicated Workers:** Deploy separate worker pods for standard inbound, standard outbound, and dedicated enterprise queues to provide strict compute isolation.
96+
97+
**Estimated Effort:** Medium
98+
**Estimated Time:** 3 to 5 days
99+
**Impact:** Essential for enterprise-grade SaaS scaling. Guarantees compute isolation for enterprise customers and bulkheads heavy outbound processing from blocking high-priority inbound traffic.

docker/localstack/init-aws.sh

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ awslocal sqs create-queue --queue-name CDC-DLQ
1414

1515
awslocal sqs create-queue --queue-name TransformOrchestrationQueue-DLQ
1616
TRANSFORM_DLQ_ARN=$(awslocal sqs get-queue-attributes --queue-url http://localhost:4566/000000000000/TransformOrchestrationQueue-DLQ --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
17-
awslocal sqs create-queue --queue-name TransformOrchestrationQueue --attributes "{\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$TRANSFORM_DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"3\\\"}\"}"
17+
awslocal sqs create-queue --queue-name TransformOrchestrationQueue --attributes "{\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$TRANSFORM_DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"3\\\"}\"}"
18+
19+
awslocal sqs create-queue --queue-name TransformComputeQueue-DLQ
20+
COMPUTE_DLQ_ARN=$(awslocal sqs get-queue-attributes --queue-url http://localhost:4566/000000000000/TransformComputeQueue-DLQ --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
21+
awslocal sqs create-queue --queue-name TransformComputeQueue --attributes "{\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$COMPUTE_DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"3\\\"}\"}"
1822

1923
awslocal sqs create-queue --queue-name DeliverQueue-DLQ
2024
DELIVER_DLQ_ARN=$(awslocal sqs get-queue-attributes --queue-url http://localhost:4566/000000000000/DeliverQueue-DLQ --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)

frontend/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"date-fns": "^4.4.0",
3333
"lucide-react": "^1.21.0",
3434
"next-themes": "^0.4.6",
35+
"node-forge": "^1.4.0",
3536
"oidc-client-ts": "^3.5.0",
3637
"react": "^19.2.7",
3738
"react-dom": "^19.2.7",
@@ -44,6 +45,7 @@
4445
"devDependencies": {
4546
"@tanstack/router-plugin": "^1.168.19",
4647
"@types/node": "^24.13.2",
48+
"@types/node-forge": "^1.3.14",
4749
"@types/react": "^19.2.17",
4850
"@types/react-dom": "^19.2.3",
4951
"@vitejs/plugin-react": "^6.0.2",

frontend/web/pnpm-lock.yaml

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/web/src/features/partners/api/IPartnersRepository.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface IPartnersRepository {
3232
// Certificates
3333
exportCertificates(partnerId: string): Promise<CertificatesExport>;
3434
rotateCertificates(partnerId: string, payload: RotateCertPayload): Promise<Partner>;
35+
generateCertificate(as2Id: string): Promise<{ public_cert_pem: string; private_key_vault_ref: string }>;
3536

3637
// Tenant Partners
3738
getTenantPartners(): Promise<Partner[]>;

frontend/web/src/features/partners/api/partnerHooks.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,13 @@ export function useRotateCertificatesMutation() {
228228
);
229229
}
230230

231+
export function useGenerateCertificateMutation() {
232+
const repo = useRepository();
233+
return useMutation({
234+
mutationFn: (as2Id: string) => repo.generateCertificate(as2Id)
235+
});
236+
}
237+
231238
export function useTestSftpConnectionMutation() {
232239
const repo = useRepository();
233240
return useMutation({

frontend/web/src/features/partners/api/partnersApi.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@ class HttpPartnersRepository implements IPartnersRepository {
124124
);
125125
}
126126

127+
generateCertificate(as2Id: string): Promise<{ public_cert_pem: string; private_key_vault_ref: string }> {
128+
return this.request(
129+
`/api/v1/platform/trading-partners/as2/certificates/generate`,
130+
{ method: 'POST', body: JSON.stringify({ as2_id: as2Id }) },
131+
);
132+
}
133+
127134
// ── Tenant Partners ────────────────────────
128135
async getTenantPartners(): Promise<Partner[]> {
129136
const data = await this.request<any[]>('/api/v1/trading-partners');

frontend/web/src/features/partners/components/As2PartnerDetails.tsx

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
import { useState } from 'react';
1+
import React, { useState } from 'react';
22
import { useForm, Controller } from 'react-hook-form';
33
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
44
import type { AS2Partner } from '../types';
55
import { useCertificatesExportQuery, useUpdatePlatformPartnerMutation, useRotateCertificatesMutation } from '../api/partnerHooks';
6+
import { pki } from 'node-forge';
67
import { Copy, Download, Loader2, ChevronDown, ChevronRight, CheckCircle2, Clock, ClipboardPaste } from 'lucide-react';
78
import { Button } from '@/components/ui/button';
89
import { Input } from '@/components/ui/input';
910
import { Label } from '@/components/ui/label';
1011
import { useToast } from '@/hooks/use-toast';
11-
import { usePlatformConfig } from '@/features/platform/api/configHooks';
12+
import { usePlatformSettings } from '@/features/platform/api/settingsHooks';
1213
import { Combobox } from '@/components/ui/combobox';
1314
import { CertificateInput } from './CertificateInput';
1415

1516
export function As2PartnerDetails({ partner, onCancel }: { partner: AS2Partner, onCancel?: () => void }) {
1617
const { toast } = useToast();
17-
const { data: platformConfig } = usePlatformConfig();
18+
const { data: platformSettings } = usePlatformSettings();
1819

1920
const updatePlatform = useUpdatePlatformPartnerMutation();
2021
const rotateCertificates = useRotateCertificatesMutation();
@@ -161,7 +162,7 @@ export function As2PartnerDetails({ partner, onCancel }: { partner: AS2Partner,
161162
control={control}
162163
render={({ field }) => (
163164
<Combobox
164-
options={platformConfig?.available_as2_receive_urls || []}
165+
options={platformSettings?.available_as2_receive_urls || []}
165166
value={field.value}
166167
onChange={field.onChange}
167168
placeholder="https://..."
@@ -199,8 +200,11 @@ export function As2PartnerDetails({ partner, onCancel }: { partner: AS2Partner,
199200
<table className="w-full text-left border-collapse">
200201
<thead>
201202
<tr className="border-b border-slate-200/60 bg-slate-50/50">
202-
<th className="px-6 py-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">Status</th>
203+
<th className="px-6 py-4 text-xs font-semibold text-slate-500 uppercase tracking-wider w-32">Status</th>
203204
<th className="px-6 py-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">Description</th>
205+
<th className="px-6 py-4 text-xs font-semibold text-slate-500 uppercase tracking-wider w-32">Issued</th>
206+
<th className="px-6 py-4 text-xs font-semibold text-slate-500 uppercase tracking-wider w-32">Expires</th>
207+
<th className="px-6 py-4 w-full"></th>
204208
</tr>
205209
</thead>
206210
<tbody className="divide-y divide-slate-100">
@@ -280,6 +284,19 @@ function CertificateRow({
280284
const [expanded, setExpanded] = useState(false);
281285
const { toast } = useToast();
282286

287+
const certInfo = React.useMemo(() => {
288+
if (!publicPem || !publicPem.includes('-----BEGIN CERTIFICATE-----')) return null;
289+
try {
290+
const cert = pki.certificateFromPem(publicPem);
291+
return {
292+
notBefore: cert.validity.notBefore.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }),
293+
notAfter: cert.validity.notAfter.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }),
294+
};
295+
} catch (e) {
296+
return null;
297+
}
298+
}, [publicPem]);
299+
283300
const handleCopy = (text: string, label: string) => {
284301
navigator.clipboard.writeText(text);
285302
toast({ title: 'Copied', description: `${label} copied to clipboard.` });
@@ -327,21 +344,35 @@ function CertificateRow({
327344
</span>
328345
)}
329346
</td>
330-
<td className="px-6 py-4 text-sm text-slate-500 w-full">
331-
<div className="flex items-center justify-between">
332-
<span>{role === 'Active' ? 'Actively used for signing and decryption' : 'In grace period for legacy traffic'}</span>
333-
<div className="flex items-center gap-3">
334-
<span className="text-xs font-medium text-slate-400 group-hover:text-indigo-600 transition-colors">
335-
{expanded ? 'Hide Details' : 'View Details'}
336-
</span>
337-
{expanded ? <ChevronDown className="w-4 h-4 text-slate-400 group-hover:text-indigo-600 transition-colors" /> : <ChevronRight className="w-4 h-4 text-slate-400 group-hover:text-indigo-600 transition-colors" />}
338-
</div>
347+
<td className="px-6 py-4 text-sm text-slate-500 whitespace-nowrap">
348+
<span>{role === 'Active' ? 'Actively used for signing and decryption' : 'In grace period for legacy traffic'}</span>
349+
</td>
350+
<td className="px-6 py-4 whitespace-nowrap">
351+
{certInfo ? (
352+
<span className="text-sm text-slate-600 font-medium">{certInfo.notBefore}</span>
353+
) : (
354+
<span className="text-sm text-slate-400 italic">Unknown</span>
355+
)}
356+
</td>
357+
<td className="px-6 py-4 whitespace-nowrap">
358+
{certInfo ? (
359+
<span className="text-sm text-slate-600 font-medium">{certInfo.notAfter}</span>
360+
) : (
361+
<span className="text-sm text-slate-400 italic">Unknown</span>
362+
)}
363+
</td>
364+
<td className="px-6 py-4 whitespace-nowrap text-right">
365+
<div className="flex items-center justify-end gap-3">
366+
<span className="text-xs font-medium text-slate-400 group-hover:text-indigo-600 transition-colors">
367+
{expanded ? 'Hide Details' : 'View Details'}
368+
</span>
369+
{expanded ? <ChevronDown className="w-4 h-4 text-slate-400 group-hover:text-indigo-600 transition-colors" /> : <ChevronRight className="w-4 h-4 text-slate-400 group-hover:text-indigo-600 transition-colors" />}
339370
</div>
340371
</td>
341372
</tr>
342373
{expanded && (
343374
<tr>
344-
<td colSpan={2} className="p-0 border-t-0">
375+
<td colSpan={5} className="p-0 border-t-0">
345376
<div className="bg-slate-50 p-4 border-b border-slate-100 flex flex-col gap-4 shadow-inner">
346377

347378
<div className="flex flex-col gap-2">

0 commit comments

Comments
 (0)