Skip to content

Commit 095b1de

Browse files
os-zhuangclaude
andcommitted
feat(GA): close P0-5 (single-instance caps) + P1-2 (unbounded growth)
Lands the two GA-gating launch-readiness items, scoped to the v1 single-instance topology decision. P0-5 (realtime/feed in-memory, no cluster coordination) — accepted as single-instance/non-HA for v1 with memory backstops: - InMemoryFeedAdapter defaults maxItems to DEFAULT_MAX_FEED_ITEMS (100k) - InMemoryRealtimeAdapter defaults maxSubscriptions to DEFAULT_MAX_SUBSCRIPTIONS (50k) - 0 = explicit unbounded opt-out; both plugins document the non-HA contract - HA (Redis realtime adapter + DB feed adapter) recorded as post-GA roadmap P1-2 (unbounded execution logs / job runs / event log): - service-job: new JobRunRetention sweeper (mirrors NotificationRetention), default-on at 30d, swept every 6h via an unref'd timer wired in JobServicePlugin kernel:ready (DB path); retentionDays:0 disables - service-messaging: retention flipped default-on (0 -> 90d). Behaviour change: notification history now auto-prunes at 90d (set 0 to keep forever) - service-automation: exec-log ring buffer cap made configurable via AutomationServicePluginOptions.maxLogSize (default unchanged at 1000) +11 tests across feed/realtime/job/automation; all touched packages build (incl. DTS) and test green. launch-readiness.md updated (P0-5/P1-2 verified, topology checkbox, HA roadmap). Changesets included. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bbd8517 commit 095b1de

20 files changed

Lines changed: 550 additions & 23 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/service-feed": minor
3+
"@objectstack/service-realtime": minor
4+
---
5+
6+
feat(P0-5): default-on memory caps for in-memory feed/realtime (single-instance GA)
7+
8+
Launch-readiness P0-5 is resolved by formally scoping v1 to **single-instance**
9+
(non-HA) and shipping memory backstops so the process-local adapters can't grow
10+
until OOM:
11+
12+
- `InMemoryFeedAdapter` now defaults `maxItems` to `DEFAULT_MAX_FEED_ITEMS`
13+
(100k) instead of unbounded. `createFeedItem` throws loudly at the cap
14+
(fail-loud beats a silent OOM kill).
15+
- `InMemoryRealtimeAdapter` now defaults `maxSubscriptions` to
16+
`DEFAULT_MAX_SUBSCRIPTIONS` (50k).
17+
- Passing `0` is an explicit unbounded opt-out (tests / short-lived processes).
18+
- Both plugin JSDocs now state the non-HA contract; HA (a Redis-backed realtime
19+
adapter over the existing `RedisPubSub`, and a DB-backed feed adapter) is a
20+
documented post-GA fast-follow.
21+
22+
**Behaviour change:** a deployment that previously relied on unbounded in-memory
23+
feed/realtime will now hit the cap and receive an error past the ceiling — set
24+
`maxItems: 0` / `maxSubscriptions: 0` to restore the old behaviour, or raise the
25+
number.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/service-job": minor
3+
---
4+
5+
feat(P1-2): default-on retention for the sys_job_run execution log
6+
7+
Every job execution appended a `sys_job_run` row with no cleanup path, so the
8+
table grew unbounded on long-running deployments (launch-readiness P1-2). New
9+
`JobRunRetention` (mirroring `service-messaging`'s `NotificationRetention`)
10+
performs a bulk `delete sys_job_run where created_at < cutoff` under a system
11+
context. `JobServicePlugin` wires it **default-on** at `kernel:ready` (DB-backed
12+
adapter only) — runs once on boot then every 6h via an unref'd timer.
13+
14+
- `retentionDays` defaults to `DEFAULT_JOB_RUN_RETENTION_DAYS` (30); set `0` to
15+
disable (rows kept forever; operator owns cleanup).
16+
- `retentionSweepMs` defaults to `DEFAULT_JOB_RUN_SWEEP_MS` (6h).
17+
18+
**Behaviour change:** job-run history older than 30 days is now pruned by
19+
default. Set `retentionDays: 0` to keep the previous keep-forever behaviour.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/service-messaging": minor
3+
"@objectstack/service-automation": minor
4+
---
5+
6+
feat(P1-2): messaging retention default-on; automation log cap configurable
7+
8+
Closes the remaining two P1-2 unbounded-growth items (launch-readiness):
9+
10+
- **service-messaging** — notification-pipeline retention is now **default-on**.
11+
`MessagingServicePlugin`'s `retentionDays` defaults to
12+
`DEFAULT_NOTIFICATION_RETENTION_DAYS` (90) instead of `0`; the
13+
already-built/tested sweeper now prunes `sys_notification` (+ delivery / inbox /
14+
receipt) older than 90 days by default. **Behaviour change:** notification
15+
history auto-prunes at 90d — set `retentionDays: 0` to keep it forever.
16+
- **service-automation** — the in-memory execution-log ring buffer (already
17+
bounded; no OOM risk) gets a tunable window via
18+
`AutomationServicePluginOptions.maxLogSize`, defaulting to
19+
`DEFAULT_MAX_EXECUTION_LOG_SIZE` (1000, unchanged). Durable
20+
`sys_automation_run`-style persistence remains a post-GA HA item.

docs/launch-readiness.md

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,17 @@ fix or acceptance.**
9797
- **Action (cluster launch):** Provide a Redis-backed realtime adapter and a
9898
DB-backed feed adapter, **or** formally restrict v1 to single-instance and
9999
document it as non-HA. Enforce a `maxItems` cap if shipping in-memory feed.
100-
- **Owner:** _______ · Verify ☐ · Sign-off ☐ · Notes: _______
100+
- **Decision = single-instance for v1 (non-HA).** Recorded: realtime/feed stay
101+
process-local for GA; HA is a post-GA fast-follow (the Redis primitive already
102+
exists — `service-cluster-redis`'s `RedisPubSub` — so the realtime adapter is a
103+
wrap, and a DB-backed feed adapter is the larger remaining piece). Both plugin
104+
JSDocs now state the non-HA contract explicitly.
105+
- **Backstops shipped:** safety caps are now **default-on**`InMemoryFeedAdapter`
106+
caps at `DEFAULT_MAX_FEED_ITEMS` (100k) and `InMemoryRealtimeAdapter` at
107+
`DEFAULT_MAX_SUBSCRIPTIONS` (50k); `createFeedItem`/`subscribe` throw loudly at
108+
the cap (fail-loud beats silent OOM). `0` is an explicit unbounded opt-out
109+
(tests / short-lived processes). +4 tests (default-cap + opt-out, both packages).
110+
- **Owner:** _______ · Verify ✅ (confirmed real @ `main`) · Sign-off ☐ · Notes: **Accepted for v1 as single-instance/non-HA**; caps default-on + documented. HA adapters tracked as post-GA roadmap. Awaiting human sign-off.
101111

102112
---
103113

@@ -130,7 +140,27 @@ fix or acceptance.**
130140
- **Risk:** Long-running pods OOM; history tables grow without bound.
131141
- **Action:** Make retention **default-on** for all event/run tables; schedule
132142
sweepers at startup; persist automation logs to a table rather than memory.
133-
- **Owner:** _______ · Verify ☐ · Sign-off ☐ · Notes: _______
143+
- **Verification finding (corrected scope):** only one of the three is truly
144+
unbounded. **automation** exec logs are *already* a bounded 1000-entry ring
145+
buffer (`engine.ts` `recordLog``splice`); memory-safe today, never persisted
146+
→ no OOM risk. **`sys_job_run`** is the real leak: append-only, zero retention.
147+
**messaging** retention was fully built + tested (`NotificationRetention`) but
148+
shipped opt-in (`retentionDays: 0`).
149+
- **Fixes shipped:**
150+
- **service-job** — new `JobRunRetention` (mirrors `NotificationRetention`):
151+
bulk `delete sys_job_run where created_at < cutoff` under a system context,
152+
**default-on** at `DEFAULT_JOB_RUN_RETENTION_DAYS` (30d), swept every 6h via
153+
an unref'd timer wired in `JobServicePlugin`'s `kernel:ready` (DB path only);
154+
`retentionDays: 0` disables. +5 tests.
155+
- **service-messaging** — flipped `retentionDays` default `0 → 90`
156+
(`DEFAULT_NOTIFICATION_RETENTION_DAYS`); sweeper/timer/shutdown already
157+
existed. **Behaviour change**: notification history now auto-prunes at 90d by
158+
default (set `0` to keep the old keep-forever behaviour). Changeset notes it.
159+
- **service-automation** — exec-log ring buffer cap made configurable via
160+
`AutomationServicePluginOptions.maxLogSize` (default unchanged at 1000,
161+
`DEFAULT_MAX_EXECUTION_LOG_SIZE`); +2 tests. Durable `sys_automation_run`-style
162+
persistence is deferred to the HA fast-follow (roadmap), not a GA blocker.
163+
- **Owner:** _______ · Verify ✅ (confirmed real @ `main`; scope corrected) · Sign-off ☐ · Notes: `sys_job_run` retention is the one true fix; messaging default-flipped; automation already bounded (now tunable). Awaiting human sign-off.
134164

135165
### P1-3 — Graceful shutdown (mostly a false positive; one real drain bug fixed)
136166
- **Area:** `core` (`kernel.ts`), `cli` (`serve.ts`), `plugin-hono-server` (`adapter.ts`)
@@ -196,6 +226,10 @@ notes, not treated as blockers.
196226
-**Unverified features — confirm stub vs. minimal, then include or exclude:**
197227
`knowledge-ragflow`, `connector-openapi` (the sweep could not locate full
198228
implementations; the packages exist — verify scope before GA).
229+
-**HA / multi-node (post-GA fast-follow, from P0-5 decision):** Redis-backed
230+
realtime adapter (wrap `service-cluster-redis`'s `RedisPubSub`), DB-backed feed
231+
adapter, and persisting automation execution logs to a table (currently a
232+
process-local 1000-entry ring buffer — see P1-2). Not needed for single-instance v1.
199233
-**Proposed ADRs (roadmap):** ADR-0021 (analytics semantic layer),
200234
ADR-0022/0023/0024 (connectors / OpenAPI→connector / MCP connectors),
201235
ADR-0025/0026 (plugin & client-UI distribution), ADR-0027 (metadata authoring
@@ -211,7 +245,8 @@ notes, not treated as blockers.
211245
- ☐ Pending changesets reviewed (4 at last check) and version bump intentional.
212246
-`pnpm run release` path verified (`build``build-console.sh``changeset publish`).
213247
- ☐ Required env vars documented for go-live (at minimum `OS_AUTH_SECRET`; see P0-1).
214-
- ☐ Deployment topology decided: **single-instance** vs. **HA/cluster** (drives P0-5).
248+
- ☑ Deployment topology decided: **single-instance** for v1 (drives P0-5; HA is a
249+
post-GA fast-follow). Realtime/feed run process-local with default-on memory caps.
215250

216251
---
217252

packages/services/service-automation/src/engine.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { describe, it, expect, beforeEach } from 'vitest';
44
import { LiteKernel } from '@objectstack/core';
5-
import { AutomationEngine } from './engine.js';
5+
import { AutomationEngine, DEFAULT_MAX_EXECUTION_LOG_SIZE } from './engine.js';
66
import { AutomationServicePlugin } from './plugin.js';
77
import { registerScreenNodes } from './builtin/screen-nodes.js';
88
import { InMemorySuspendedRunStore } from './suspended-run-store.js';
@@ -30,6 +30,30 @@ describe('AutomationEngine', () => {
3030
engine = new AutomationEngine(createTestLogger());
3131
});
3232

33+
describe('Execution-log ring buffer (P1-2)', () => {
34+
it('defaults the cap to DEFAULT_MAX_EXECUTION_LOG_SIZE', () => {
35+
const e = new AutomationEngine(createTestLogger());
36+
expect(DEFAULT_MAX_EXECUTION_LOG_SIZE).toBeGreaterThan(0);
37+
expect((e as unknown as { maxLogSize: number }).maxLogSize).toBe(
38+
DEFAULT_MAX_EXECUTION_LOG_SIZE,
39+
);
40+
});
41+
42+
it('honours a configured maxLogSize and evicts oldest beyond it', async () => {
43+
const e = new AutomationEngine(createTestLogger(), undefined, { maxLogSize: 3 });
44+
expect((e as unknown as { maxLogSize: number }).maxLogSize).toBe(3);
45+
46+
// Drive the private ring buffer directly: push 5, keep newest 3.
47+
const rec = (e as any).recordLog.bind(e);
48+
for (let i = 0; i < 5; i++) {
49+
rec({ id: `run_${i}`, flowName: 'f', status: 'success' });
50+
}
51+
const buf = (e as unknown as { executionLogs: Array<{ id: string }> }).executionLogs;
52+
expect(buf).toHaveLength(3);
53+
expect(buf.map((l) => l.id)).toEqual(['run_2', 'run_3', 'run_4']);
54+
});
55+
});
56+
3357
describe('Node Executor Registration', () => {
3458
it('should register a node executor', () => {
3559
const executor: NodeExecutor = {

packages/services/service-automation/src/engine.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,26 @@ export interface ConnectorDescriptor {
197197

198198
// ─── Core Automation Engine ─────────────────────────────────────────
199199

200+
/**
201+
* Default ceiling on the in-memory execution-log ring buffer. Execution logs are
202+
* process-local and diagnostic only (launch-readiness.md P1-2); the buffer keeps
203+
* the most recent N entries and evicts the oldest, so memory is bounded
204+
* regardless of throughput. Operators tune the window via
205+
* {@link AutomationServicePluginOptions.maxLogSize}. Durable, queryable run
206+
* history is the DB-backed `sys_automation_run` store (a post-GA HA item), not
207+
* this buffer.
208+
*/
209+
export const DEFAULT_MAX_EXECUTION_LOG_SIZE = 1000;
210+
211+
/** Construction options for {@link AutomationEngine}. */
212+
export interface AutomationEngineOptions {
213+
/**
214+
* Max in-memory execution-log entries retained (ring buffer; oldest evicted).
215+
* Defaults to {@link DEFAULT_MAX_EXECUTION_LOG_SIZE}. Must be > 0.
216+
*/
217+
maxLogSize?: number;
218+
}
219+
200220
/**
201221
* Execution step log entry. Part of a {@link SuspendedRun}'s persisted state, so
202222
* it survives serialization to a durable {@link SuspendedRunStore}.
@@ -334,7 +354,7 @@ export class AutomationEngine implements IAutomationService {
334354
/** Connectors registered by integration plugins, keyed by connector name (ADR-0018 §Addendum). */
335355
private connectors = new Map<string, RegisteredConnector>();
336356
private executionLogs: ExecutionLogEntry[] = [];
337-
private maxLogSize = 1000;
357+
private readonly maxLogSize: number;
338358
private logger: Logger;
339359
/**
340360
* Runs paused at a node, keyed by runId (ADR-0019). In-memory hot cache —
@@ -354,9 +374,10 @@ export class AutomationEngine implements IAutomationService {
354374
*/
355375
private resuming = new Set<string>();
356376

357-
constructor(logger: Logger, store?: SuspendedRunStore) {
377+
constructor(logger: Logger, store?: SuspendedRunStore, options?: AutomationEngineOptions) {
358378
this.logger = logger;
359379
this.store = store;
380+
this.maxLogSize = options?.maxLogSize ?? DEFAULT_MAX_EXECUTION_LOG_SIZE;
360381
}
361382

362383
/**

packages/services/service-automation/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
// Core engine
4-
export { AutomationEngine } from './engine.js';
4+
export { AutomationEngine, DEFAULT_MAX_EXECUTION_LOG_SIZE } from './engine.js';
55
export type {
6+
AutomationEngineOptions,
67
NodeExecutor,
78
NodeExecutionResult,
89
FlowTrigger,

packages/services/service-automation/src/plugin.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ export interface AutomationServicePluginOptions {
2424
* survives a process restart and can be resumed after a cold boot.
2525
*/
2626
suspendedRunStore?: 'auto' | 'memory';
27+
/**
28+
* Max in-memory execution-log entries retained per process (ring buffer;
29+
* oldest evicted). The buffer is diagnostic-only and already bounded
30+
* (launch-readiness.md P1-2); this just makes the window tunable. Defaults
31+
* to {@link DEFAULT_MAX_EXECUTION_LOG_SIZE} (1000).
32+
*/
33+
maxLogSize?: number;
2734
}
2835

2936
/**
@@ -71,7 +78,9 @@ export class AutomationServicePlugin implements Plugin {
7178
}
7279

7380
async init(ctx: PluginContext): Promise<void> {
74-
this.engine = new AutomationEngine(ctx.logger);
81+
this.engine = new AutomationEngine(ctx.logger, undefined, {
82+
maxLogSize: this.options.maxLogSize,
83+
});
7584

7685
// Register as global service — other plugins access via ctx.getService('automation')
7786
ctx.registerService('automation', this.engine);

packages/services/service-feed/src/feed-service-plugin.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ export interface FeedServicePluginOptions {
2121
* Registers a Feed/Chatter service with the kernel during the init phase.
2222
* Currently supports in-memory storage for single-process environments.
2323
*
24+
* **v1 deployment contract (launch-readiness.md P0-5): single-instance only.**
25+
* The in-memory adapter is process-local and non-durable — feed data is lost on
26+
* restart and is not shared across nodes. A default `maxItems` safety cap is
27+
* enforced (see {@link InMemoryFeedAdapter}) so the store can't grow until OOM.
28+
* HA (a DB-backed feed adapter) is a post-GA fast-follow.
29+
*
2430
* @example
2531
* ```ts
2632
* import { ObjectKernel } from '@objectstack/core';

packages/services/service-feed/src/in-memory-feed-adapter.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { InMemoryFeedAdapter } from './in-memory-feed-adapter';
4+
import { InMemoryFeedAdapter, DEFAULT_MAX_FEED_ITEMS } from './in-memory-feed-adapter';
55
import type { IFeedService, CreateFeedItemInput } from '@objectstack/spec/contracts';
66

77
/** Helper to create a standard comment input. */
@@ -125,6 +125,24 @@ describe('InMemoryFeedAdapter', () => {
125125
.rejects.toThrow(/Maximum feed item limit reached/);
126126
});
127127

128+
it('enforces a default maxItems cap when none is configured (P0-5 backstop)', async () => {
129+
// The cap is default-on so a long-running single-instance feed can't grow
130+
// until OOM. Adapter constructed with no options uses the default cap.
131+
const feed = new InMemoryFeedAdapter();
132+
expect(DEFAULT_MAX_FEED_ITEMS).toBeGreaterThan(0);
133+
expect(Number.isFinite(DEFAULT_MAX_FEED_ITEMS)).toBe(true);
134+
expect((feed as unknown as { maxItems: number }).maxItems).toBe(DEFAULT_MAX_FEED_ITEMS);
135+
});
136+
137+
it('treats maxItems: 0 as an explicit unbounded opt-out', async () => {
138+
const feed = new InMemoryFeedAdapter({ maxItems: 0 });
139+
expect((feed as unknown as { maxItems: number }).maxItems).toBe(0);
140+
for (let i = 0; i < 5; i++) {
141+
await feed.createFeedItem(commentInput({ body: `c${i}` }));
142+
}
143+
expect(feed.getItemCount()).toBe(5);
144+
});
145+
128146
// ==========================================
129147
// Feed Listing & Filtering
130148
// ==========================================

0 commit comments

Comments
 (0)