@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.
Files changed (53) hide show
  1. package/README.md +65 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +85 -0
  4. package/dist/commands/add.d.ts +6 -0
  5. package/dist/commands/add.js +173 -0
  6. package/dist/commands/adopt.d.ts +5 -0
  7. package/dist/commands/adopt.js +286 -0
  8. package/dist/commands/init.d.ts +5 -0
  9. package/dist/commands/init.js +295 -0
  10. package/dist/commands/list.d.ts +6 -0
  11. package/dist/commands/list.js +105 -0
  12. package/dist/commands/loop.d.ts +63 -0
  13. package/dist/commands/loop.js +765 -0
  14. package/dist/commands/status.d.ts +40 -0
  15. package/dist/commands/status.js +303 -0
  16. package/dist/commands/status.test.d.ts +1 -0
  17. package/dist/commands/status.test.js +118 -0
  18. package/dist/commands/update.d.ts +8 -0
  19. package/dist/commands/update.js +204 -0
  20. package/dist/lib/capability.d.ts +17 -0
  21. package/dist/lib/capability.js +172 -0
  22. package/dist/lib/config.d.ts +70 -0
  23. package/dist/lib/config.js +59 -0
  24. package/dist/lib/db-status.d.ts +64 -0
  25. package/dist/lib/db-status.js +257 -0
  26. package/dist/lib/env.d.ts +25 -0
  27. package/dist/lib/env.js +116 -0
  28. package/dist/lib/execute.d.ts +21 -0
  29. package/dist/lib/execute.js +228 -0
  30. package/dist/lib/interpolate.d.ts +4 -0
  31. package/dist/lib/interpolate.js +13 -0
  32. package/dist/lib/manifest.d.ts +54 -0
  33. package/dist/lib/manifest.js +11 -0
  34. package/dist/lib/preflight.d.ts +31 -0
  35. package/dist/lib/preflight.js +402 -0
  36. package/dist/lib/profile.d.ts +11 -0
  37. package/dist/lib/profile.js +73 -0
  38. package/dist/lib/project.d.ts +36 -0
  39. package/dist/lib/project.js +71 -0
  40. package/dist/lib/project.test.d.ts +1 -0
  41. package/dist/lib/project.test.js +118 -0
  42. package/dist/lib/rewire.d.ts +19 -0
  43. package/dist/lib/rewire.js +222 -0
  44. package/dist/lib/safe-io.d.ts +66 -0
  45. package/dist/lib/safe-io.js +167 -0
  46. package/dist/lib/scaffold.d.ts +87 -0
  47. package/dist/lib/scaffold.js +548 -0
  48. package/dist/lib/update.d.ts +73 -0
  49. package/dist/lib/update.js +379 -0
  50. package/package.json +46 -0
  51. package/profiles/web-app/profile.md +113 -0
  52. package/profiles/web-app/reference/architecture.ref.md +65 -0
  53. 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