@boopkit/cli 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/README.md +65 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +85 -0
- package/dist/commands/add.d.ts +6 -0
- package/dist/commands/add.js +173 -0
- package/dist/commands/adopt.d.ts +5 -0
- package/dist/commands/adopt.js +286 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +295 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.js +105 -0
- package/dist/commands/loop.d.ts +63 -0
- package/dist/commands/loop.js +765 -0
- package/dist/commands/status.d.ts +40 -0
- package/dist/commands/status.js +303 -0
- package/dist/commands/status.test.d.ts +1 -0
- package/dist/commands/status.test.js +118 -0
- package/dist/commands/update.d.ts +8 -0
- package/dist/commands/update.js +204 -0
- package/dist/lib/capability.d.ts +17 -0
- package/dist/lib/capability.js +172 -0
- package/dist/lib/config.d.ts +70 -0
- package/dist/lib/config.js +59 -0
- package/dist/lib/db-status.d.ts +64 -0
- package/dist/lib/db-status.js +257 -0
- package/dist/lib/env.d.ts +25 -0
- package/dist/lib/env.js +116 -0
- package/dist/lib/execute.d.ts +21 -0
- package/dist/lib/execute.js +228 -0
- package/dist/lib/interpolate.d.ts +4 -0
- package/dist/lib/interpolate.js +13 -0
- package/dist/lib/manifest.d.ts +54 -0
- package/dist/lib/manifest.js +11 -0
- package/dist/lib/preflight.d.ts +31 -0
- package/dist/lib/preflight.js +402 -0
- package/dist/lib/profile.d.ts +11 -0
- package/dist/lib/profile.js +73 -0
- package/dist/lib/project.d.ts +36 -0
- package/dist/lib/project.js +71 -0
- package/dist/lib/project.test.d.ts +1 -0
- package/dist/lib/project.test.js +118 -0
- package/dist/lib/rewire.d.ts +19 -0
- package/dist/lib/rewire.js +222 -0
- package/dist/lib/safe-io.d.ts +66 -0
- package/dist/lib/safe-io.js +167 -0
- package/dist/lib/scaffold.d.ts +87 -0
- package/dist/lib/scaffold.js +548 -0
- package/dist/lib/update.d.ts +73 -0
- package/dist/lib/update.js +379 -0
- package/package.json +46 -0
- package/profiles/web-app/profile.md +113 -0
- package/profiles/web-app/reference/architecture.ref.md +65 -0
- package/profiles/web-app/reference/message-queue-patterns.ref.md +247 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Message Queue Patterns
|
|
2
|
+
|
|
3
|
+
Architectural guidance for RabbitMQ consumer patterns in worker processes. These patterns are not scaffolded by `boop init` — they document proven approaches for when your project adds async processing. Covers consumer modes, retry/DLQ, buffered batch ACK, and backpressure via prefetch.
|
|
4
|
+
|
|
5
|
+
## Infrastructure
|
|
6
|
+
|
|
7
|
+
- **Provider**: RabbitMQ via Heroku CLOUDAMQP
|
|
8
|
+
- **Library**: `amqplib` (v0.10.x)
|
|
9
|
+
- **Service**: `src/services/message_queue.service.ts`
|
|
10
|
+
- **Queue naming**: `{QUEUE_PREFIX}/{queue-name}` — `QUEUE_PREFIX` is user-specific in locahost (e.g., `mike`), empty in dev, staging, production
|
|
11
|
+
|
|
12
|
+
## Publishing
|
|
13
|
+
|
|
14
|
+
`MessageQueueService.publish()` uses a **persistent AMQP connection** that is lazy-initialized on first call and reused across all subsequent calls within the same process.
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
const { message_queue: MessageQueueService } = services;
|
|
18
|
+
await MessageQueueService.publish('batch-ready', [message1, message2]);
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Connection lifecycle
|
|
22
|
+
|
|
23
|
+
- **Lazy init**: No connection is opened until the first `publish()` call
|
|
24
|
+
- **Reuse**: The same connection and channel are reused for all subsequent publishes
|
|
25
|
+
- **Auto-reconnect**: If the connection drops (`error` or `close` event), the cached reference is cleared and the next `publish()` call opens a fresh connection
|
|
26
|
+
- **Error recovery**: If `publish()` catches an error (e.g., channel closed), it resets connection state and returns `false` — the next call will reconnect
|
|
27
|
+
- **Shutdown**: The publish connection is tracked in `listenerConnections` and closed by the coordinated SIGINT handler alongside listener connections
|
|
28
|
+
|
|
29
|
+
### Why persistent (not per-call)
|
|
30
|
+
|
|
31
|
+
The original implementation opened a new AMQP connection for every `publish()` call, then immediately closed it. This works for infrequent publishing but fails under rapid iteration (e.g., `ch-export-push` publishing 177 batch-ready messages in seconds). CloudAMQP's shared plan rate-limits connection churn, causing `ECONNREFUSED` errors.
|
|
32
|
+
|
|
33
|
+
### Callers
|
|
34
|
+
|
|
35
|
+
| Caller | Queue | Frequency |
|
|
36
|
+
|--------|-------|-----------|
|
|
37
|
+
| Scheduler jobs | various | 1-2 per 10-min cycle |
|
|
38
|
+
|
|
39
|
+
## Consumer Modes
|
|
40
|
+
|
|
41
|
+
The `MessageQueueService.listen()` function supports two modes controlled by `opts.manualAck`:
|
|
42
|
+
|
|
43
|
+
### Auto-ACK Mode (default)
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
MessageQueueService.listen('queue-name', handler);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- Handler signature: `async handler(msg) → boolean`
|
|
50
|
+
- Returns `true` → message ACKed immediately
|
|
51
|
+
- Returns `false` → sent to retry queue with exponential backoff
|
|
52
|
+
- Throws → NACKed to DLQ after max retries
|
|
53
|
+
- **Use case**: Simple request-response consumers (e.g., entity replication, long running jobs)
|
|
54
|
+
|
|
55
|
+
### Manual-ACK Mode
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
MessageQueueService.listen('queue-name', handler, { manualAck: true, prefetch: N });
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- Handler signature: `async handler(msg, ack, nack)`
|
|
62
|
+
- `ack()` and `nack()` are guarded — only the first call takes effect
|
|
63
|
+
- Handler is responsible for calling `ack()` or `nack()` (can be deferred)
|
|
64
|
+
- If handler throws without settling → message moved to DLQ
|
|
65
|
+
- **Use case**: Buffered/batch consumers where ACK is deferred until a batch operation completes
|
|
66
|
+
|
|
67
|
+
## Retry & Dead Letter Queue (DLQ)
|
|
68
|
+
|
|
69
|
+
Every queue gets automatic retry and DLQ infrastructure:
|
|
70
|
+
|
|
71
|
+
| Queue | Purpose |
|
|
72
|
+
|-------|---------|
|
|
73
|
+
| `{name}` | Main queue — consumer listens here |
|
|
74
|
+
| `{name}.retry` | Retry queue — messages wait here with TTL before redelivery to main queue |
|
|
75
|
+
| `{name}.failed` | DLQ — messages that exceed max retries land here |
|
|
76
|
+
|
|
77
|
+
**Retry behavior**:
|
|
78
|
+
- Exponential backoff: `min(BASE * 2^retryCount, MAX)` — default BASE=4s, MAX=60s
|
|
79
|
+
- Max retries: 5 (configurable via `MAX_MESSAGE_RETRIES` env var)
|
|
80
|
+
- Retry count tracked in `x-retry-count` message header
|
|
81
|
+
- After max retries → NACK without requeue → routed to DLQ via dead letter exchange
|
|
82
|
+
|
|
83
|
+
## Buffered Batch ACK Pattern
|
|
84
|
+
|
|
85
|
+
For consumers that buffer events and flush in batches (e.g., the S3 batch writer), ACK is deferred until the batch operation completes. This pattern combines manual-ACK mode with prefetch-based backpressure.
|
|
86
|
+
|
|
87
|
+
### How it works
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
RabbitMQ Consumer (worker) S3 Batch Writer
|
|
91
|
+
│ │ │
|
|
92
|
+
│── deliver msg 1 ──>│ │
|
|
93
|
+
│── deliver msg 2 ──>│── add(event, messageRef) ──>│ buffer
|
|
94
|
+
│── deliver msg 3 ──>│── add(event, messageRef) ──>│ buffer
|
|
95
|
+
│── deliver msg 4 ──>│── add(event, messageRef) ──>│ buffer
|
|
96
|
+
│── deliver msg 5 ──>│── add(event, messageRef) ──>│ buffer ──> threshold!
|
|
97
|
+
│ │ │
|
|
98
|
+
│ │ flush to S3 (JSONL.gz)
|
|
99
|
+
│ │ │
|
|
100
|
+
│ │ S3 upload success
|
|
101
|
+
│ │ │
|
|
102
|
+
│<─── ACK msg 1 ────│<──── ack(ref1) ─────────────│
|
|
103
|
+
│<─── ACK msg 2 ────│<──── ack(ref2) ─────────────│
|
|
104
|
+
│<─── ACK msg 3 ────│<──── ack(ref3) ─────────────│
|
|
105
|
+
│<─── ACK msg 4 ────│<──── ack(ref4) ─────────────│
|
|
106
|
+
│<─── ACK msg 5 ────│<──── ack(ref5) ─────────────│
|
|
107
|
+
│ │ │
|
|
108
|
+
│── deliver msg 6 ──>│ (next batch) │
|
|
109
|
+
...
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### MessageRef interface
|
|
113
|
+
|
|
114
|
+
The consumer wraps the MQ channel and message into a `MessageRef` passed to the batch writer:
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
interface MessageRef {
|
|
118
|
+
channel: { ack: () => void; nack: () => void };
|
|
119
|
+
message: any; // original amqplib message (unused, kept for debugging)
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The batch writer stores message refs alongside buffered events. On flush:
|
|
124
|
+
- **Success**: calls `ref.channel.ack()` for each message in the batch
|
|
125
|
+
- **Failure**: calls `ref.channel.nack()` for each message → triggers retry
|
|
126
|
+
|
|
127
|
+
### Implementation
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// In access_logs.service.ts — onLogRequest handler
|
|
131
|
+
S3BatchWriterService.add(logEntry, {
|
|
132
|
+
channel: { ack: () => ack(), nack: () => nack() },
|
|
133
|
+
message: msg
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// In s3_batch_writer.service.ts — flushToS3
|
|
137
|
+
// After successful S3 upload:
|
|
138
|
+
for (const ref of snap.messageRefs) {
|
|
139
|
+
ref.channel.ack(); // ACK each buffered message
|
|
140
|
+
}
|
|
141
|
+
// On upload failure:
|
|
142
|
+
for (const ref of snap.messageRefs) {
|
|
143
|
+
ref.channel.nack(); // NACK → retry with backoff
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Prefetch & Backpressure
|
|
148
|
+
|
|
149
|
+
### The prefetch-batch alignment rule
|
|
150
|
+
|
|
151
|
+
**When using buffered batch ACK, `prefetch` must equal the batch size (`MAX_EVENTS`).**
|
|
152
|
+
|
|
153
|
+
| Prefetch | Behavior | Problem |
|
|
154
|
+
|----------|----------|---------|
|
|
155
|
+
| `1` | RabbitMQ delivers 1 message, waits for ACK before sending next | **Deadlock**: batch never fills, message sits unACKed forever (until age timer) |
|
|
156
|
+
| `= MAX_EVENTS` | RabbitMQ delivers exactly N messages, batch fills, flush, ACK, next N delivered | **Correct**: clean lockstep between delivery and batch processing |
|
|
157
|
+
| `0` (unlimited) | RabbitMQ delivers all available messages immediately | **Risky**: buffer grows unbounded; concurrent flush triggers may race |
|
|
158
|
+
|
|
159
|
+
### How prefetch creates lockstep
|
|
160
|
+
|
|
161
|
+
With `prefetch = MAX_EVENTS = 5`:
|
|
162
|
+
|
|
163
|
+
1. RabbitMQ delivers 5 messages (consumer's unACKed limit)
|
|
164
|
+
2. All 5 buffered → threshold hit → flush → S3 upload → ACK all 5
|
|
165
|
+
3. RabbitMQ sees 5 ACKs → delivers next 5
|
|
166
|
+
4. Repeat
|
|
167
|
+
|
|
168
|
+
If fewer than `MAX_EVENTS` messages exist in the queue, the age-check timer (default 30s interval, 10min max age) eventually flushes the partial batch.
|
|
169
|
+
|
|
170
|
+
### Configuration
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
// In access_logs.service.ts — setupQueueListener()
|
|
174
|
+
const prefetch = S3BatchWriterService.config.MAX_EVENTS;
|
|
175
|
+
await MessageQueueService.listen('access-logs/logRequest', onLogRequest, {
|
|
176
|
+
manualAck: true,
|
|
177
|
+
prefetch
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Environment variables**:
|
|
182
|
+
| Variable | Default | Description |
|
|
183
|
+
|----------|---------|-------------|
|
|
184
|
+
| `S3_BATCH_MAX_EVENTS` | `10000` | Events per batch (also sets prefetch) |
|
|
185
|
+
| `S3_BATCH_MAX_BYTES` | `10485760` (10MB) | Byte size threshold |
|
|
186
|
+
| `S3_BATCH_MAX_AGE_MS` | `600000` (10min) | Maximum age before flush |
|
|
187
|
+
| `S3_BATCH_TIMER_INTERVAL_MS` | `30000` (30s) | Age-check timer interval |
|
|
188
|
+
|
|
189
|
+
## Known Issues
|
|
190
|
+
|
|
191
|
+
## Service Init Ordering
|
|
192
|
+
|
|
193
|
+
Services are loaded and initialized alphabetically by `loadServices()` in `src/services/index.ts`. This creates an ordering dependency:
|
|
194
|
+
|
|
195
|
+
- `access_logs` (a) initializes before `s3_batch_writer` (s)
|
|
196
|
+
- If `access_logs` subscribes to a queue during `init()`, messages arrive before the batch writer's flush callback is wired
|
|
197
|
+
|
|
198
|
+
**Solution**: Queue subscription is deferred to `setupQueueListener()`, called by the worker **after** all services are initialized:
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
loadServices() → all service.init() in alphabetical order
|
|
202
|
+
setupQueueListeners() → AccessLogsService.setupQueueListener() first
|
|
203
|
+
then other queue listeners
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
This ensures the S3 batch writer is fully ready (flush callback, timer, shutdown handlers) before any messages are consumed.
|
|
207
|
+
|
|
208
|
+
## Shutdown Behavior
|
|
209
|
+
|
|
210
|
+
### Known issue: Shutdown flush ACK race
|
|
211
|
+
|
|
212
|
+
During SIGINT/SIGTERM, both the MQ service and batch writer register independent signal handlers:
|
|
213
|
+
- **MQ handler**: closes the AMQP connection immediately
|
|
214
|
+
- **Batch writer handler**: flushes remaining events to S3, then tries to ACK
|
|
215
|
+
|
|
216
|
+
The MQ connection closes before the batch writer can ACK. The S3 upload succeeds but ACKs fail with `Channel closed`.
|
|
217
|
+
|
|
218
|
+
**Impact**: Low — data is safe in S3, unACKed messages are redelivered on next start, `event_id`-based dedup in v2's ReplacingMergeTree handles duplicates.
|
|
219
|
+
|
|
220
|
+
**Fix**: Requires coordinated shutdown ordering (batch writer flush+ACK → MQ close → process exit).
|
|
221
|
+
|
|
222
|
+
## Queue Listeners Registration
|
|
223
|
+
|
|
224
|
+
Queue listeners are registered in `src/worker.ts`:
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
// Access-logs listener — set up first, after all services init
|
|
228
|
+
await AccessLogsService.setupQueueListener();
|
|
229
|
+
|
|
230
|
+
// Other listeners — auto-ACK mode
|
|
231
|
+
const listeners = [
|
|
232
|
+
{ queue: 'clickhouse-entity-replication', handler: ... },
|
|
233
|
+
{ queue: 'web_log_uploaded', handler: ... },
|
|
234
|
+
{ queue: 's3-loader', handler: ... },
|
|
235
|
+
{ queue: 's3-loader-watchdog', handler: ... },
|
|
236
|
+
];
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## References
|
|
240
|
+
|
|
241
|
+
These file paths follow the conventions described above. They represent the expected locations when you implement async processing in your project:
|
|
242
|
+
|
|
243
|
+
- `src/services/message_queue.service.ts` — MQ service (publish, listen, retry/DLQ)
|
|
244
|
+
- `src/services/access_logs.service.ts` — S3 batch consumer, `setupQueueListener()`
|
|
245
|
+
- `src/services/s3_batch_writer.service.ts` — batch buffer, flush, ACK/NACK
|
|
246
|
+
- `src/worker.ts` — consumer registration
|
|
247
|
+
- [Architecture](./architecture.ref.md) — system overview
|