@wopr-network/platform-core 1.71.0 → 1.72.1
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/dist/proxy/manager.js +1 -1
- package/dist/proxy/manager.test.js +3 -2
- package/dist/server/boot-config.d.ts +11 -1
- package/dist/server/container.js +21 -10
- package/dist/server/services/__tests__/hot-pool.test.d.ts +6 -0
- package/dist/server/services/__tests__/hot-pool.test.js +26 -0
- package/dist/server/services/__tests__/in-memory-pool-repository.d.ts +21 -0
- package/dist/server/services/__tests__/in-memory-pool-repository.js +57 -0
- package/dist/server/services/__tests__/pool-repository.test.d.ts +7 -0
- package/dist/server/services/__tests__/pool-repository.test.js +108 -0
- package/package.json +1 -1
- package/src/proxy/manager.test.ts +3 -2
- package/src/proxy/manager.ts +1 -1
- package/src/server/boot-config.ts +12 -1
- package/src/server/container.ts +20 -11
- package/src/server/services/__tests__/hot-pool.test.ts +30 -0
- package/src/server/services/__tests__/in-memory-pool-repository.ts +66 -0
- package/src/server/services/__tests__/pool-repository.test.ts +135 -0
package/dist/proxy/manager.js
CHANGED
|
@@ -206,8 +206,8 @@ export class ProxyManager {
|
|
|
206
206
|
await this.reload();
|
|
207
207
|
}
|
|
208
208
|
catch (err) {
|
|
209
|
+
logger.warn("Proxy manager failed to connect to Caddy — continuing without proxy management", { err });
|
|
209
210
|
await this.stop();
|
|
210
|
-
throw err;
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
async stop() {
|
|
@@ -286,9 +286,10 @@ describe("ProxyManager", () => {
|
|
|
286
286
|
});
|
|
287
287
|
});
|
|
288
288
|
describe("start rollback on failure", () => {
|
|
289
|
-
it("calls stop() if reload fails during start()", async () => {
|
|
289
|
+
it("calls stop() and continues if reload fails during start()", async () => {
|
|
290
290
|
vi.mocked(fetch).mockRejectedValueOnce(new Error("connection refused"));
|
|
291
|
-
|
|
291
|
+
// start() should NOT throw — proxy failure is non-fatal
|
|
292
|
+
await expect(manager.start()).resolves.toBeUndefined();
|
|
292
293
|
expect(manager.isRunning).toBe(false);
|
|
293
294
|
});
|
|
294
295
|
});
|
|
@@ -20,8 +20,18 @@ export interface RoutePlugin {
|
|
|
20
20
|
export interface BootConfig {
|
|
21
21
|
/** Short product identifier (e.g. "paperclip", "wopr", "holyship"). */
|
|
22
22
|
slug: string;
|
|
23
|
-
/** PostgreSQL connection string. */
|
|
23
|
+
/** PostgreSQL connection string. Required unless `pool` is provided. */
|
|
24
24
|
databaseUrl: string;
|
|
25
|
+
/**
|
|
26
|
+
* Pre-created PostgreSQL connection pool. When provided, buildContainer
|
|
27
|
+
* reuses this pool and skips pool creation + Drizzle migrations. The
|
|
28
|
+
* caller is responsible for running their own migrations before calling
|
|
29
|
+
* buildContainer.
|
|
30
|
+
*
|
|
31
|
+
* Use this when the product has its own migration set (e.g. wopr-platform
|
|
32
|
+
* generates migrations locally from the shared schema).
|
|
33
|
+
*/
|
|
34
|
+
pool?: import("pg").Pool;
|
|
25
35
|
/** Bind host (default "0.0.0.0"). */
|
|
26
36
|
host?: string;
|
|
27
37
|
/** Bind port (default 3001). */
|
package/dist/server/container.js
CHANGED
|
@@ -20,20 +20,31 @@
|
|
|
20
20
|
* `bootConfig.features`. Disabled features yield `null`.
|
|
21
21
|
*/
|
|
22
22
|
export async function buildContainer(bootConfig) {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// 1. Database pool — reuse existing or create new
|
|
24
|
+
let pool;
|
|
25
|
+
if (bootConfig.pool) {
|
|
26
|
+
pool = bootConfig.pool;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
if (!bootConfig.databaseUrl) {
|
|
30
|
+
throw new Error("buildContainer: databaseUrl is required when pool is not provided");
|
|
31
|
+
}
|
|
32
|
+
const { Pool: PgPool } = await import("pg");
|
|
33
|
+
pool = new PgPool({ connectionString: bootConfig.databaseUrl });
|
|
25
34
|
}
|
|
26
|
-
// 1. Database pool
|
|
27
|
-
const { Pool: PgPool } = await import("pg");
|
|
28
|
-
const pool = new PgPool({ connectionString: bootConfig.databaseUrl });
|
|
29
35
|
// 2. Drizzle ORM instance
|
|
30
36
|
const { createDb } = await import("../db/index.js");
|
|
31
37
|
const db = createDb(pool);
|
|
32
|
-
// 3. Run Drizzle migrations
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
// 3. Run Drizzle migrations (skip when caller provided their own pool —
|
|
39
|
+
// they are responsible for running product-specific migrations first)
|
|
40
|
+
if (!bootConfig.pool) {
|
|
41
|
+
const { migrate } = await import("drizzle-orm/node-postgres/migrator");
|
|
42
|
+
const path = await import("node:path");
|
|
43
|
+
const { fileURLToPath } = await import("node:url");
|
|
44
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
const migrationsFolder = path.resolve(__dirname, "..", "..", "drizzle", "migrations");
|
|
46
|
+
await migrate(db, { migrationsFolder });
|
|
47
|
+
}
|
|
37
48
|
// 4. Bootstrap product config from DB (auto-seeds from presets if needed)
|
|
38
49
|
const { platformBoot } = await import("../product-config/boot.js");
|
|
39
50
|
const { config: productConfig, service: productConfigService } = await platformBoot({ slug: bootConfig.slug, db });
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for hot pool service functions (getPoolSize, setPoolSize).
|
|
3
|
+
*
|
|
4
|
+
* Uses InMemoryPoolRepository to test service logic without Docker or DB.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { getPoolSize, setPoolSize } from "../hot-pool.js";
|
|
8
|
+
import { InMemoryPoolRepository } from "./in-memory-pool-repository.js";
|
|
9
|
+
describe("hot pool service", () => {
|
|
10
|
+
describe("getPoolSize / setPoolSize", () => {
|
|
11
|
+
it("returns default pool size", async () => {
|
|
12
|
+
const repo = new InMemoryPoolRepository();
|
|
13
|
+
expect(await getPoolSize(repo)).toBe(2);
|
|
14
|
+
});
|
|
15
|
+
it("sets and reads pool size", async () => {
|
|
16
|
+
const repo = new InMemoryPoolRepository();
|
|
17
|
+
await setPoolSize(repo, 10);
|
|
18
|
+
expect(await getPoolSize(repo)).toBe(10);
|
|
19
|
+
});
|
|
20
|
+
it("pool size of 0 is valid", async () => {
|
|
21
|
+
const repo = new InMemoryPoolRepository();
|
|
22
|
+
await setPoolSize(repo, 0);
|
|
23
|
+
expect(await getPoolSize(repo)).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory IPoolRepository for testing.
|
|
3
|
+
* FIFO claiming, dead instance handling — no DB required.
|
|
4
|
+
*/
|
|
5
|
+
import type { IPoolRepository, PoolInstance } from "../pool-repository.js";
|
|
6
|
+
export declare class InMemoryPoolRepository implements IPoolRepository {
|
|
7
|
+
private poolSize;
|
|
8
|
+
private instances;
|
|
9
|
+
getPoolSize(): Promise<number>;
|
|
10
|
+
setPoolSize(size: number): Promise<void>;
|
|
11
|
+
warmCount(): Promise<number>;
|
|
12
|
+
insertWarm(id: string, containerId: string): Promise<void>;
|
|
13
|
+
listWarm(): Promise<PoolInstance[]>;
|
|
14
|
+
markDead(id: string): Promise<void>;
|
|
15
|
+
deleteDead(): Promise<void>;
|
|
16
|
+
claimWarm(tenantId: string, name: string): Promise<{
|
|
17
|
+
id: string;
|
|
18
|
+
containerId: string;
|
|
19
|
+
} | null>;
|
|
20
|
+
updateInstanceStatus(id: string, status: string): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory IPoolRepository for testing.
|
|
3
|
+
* FIFO claiming, dead instance handling — no DB required.
|
|
4
|
+
*/
|
|
5
|
+
export class InMemoryPoolRepository {
|
|
6
|
+
poolSize = 2;
|
|
7
|
+
instances = [];
|
|
8
|
+
async getPoolSize() {
|
|
9
|
+
return this.poolSize;
|
|
10
|
+
}
|
|
11
|
+
async setPoolSize(size) {
|
|
12
|
+
this.poolSize = size;
|
|
13
|
+
}
|
|
14
|
+
async warmCount() {
|
|
15
|
+
return this.instances.filter((i) => i.status === "warm").length;
|
|
16
|
+
}
|
|
17
|
+
async insertWarm(id, containerId) {
|
|
18
|
+
this.instances.push({
|
|
19
|
+
id,
|
|
20
|
+
containerId,
|
|
21
|
+
status: "warm",
|
|
22
|
+
tenantId: null,
|
|
23
|
+
name: null,
|
|
24
|
+
createdAt: new Date(),
|
|
25
|
+
claimedAt: null,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async listWarm() {
|
|
29
|
+
return this.instances.filter((i) => i.status === "warm").map(({ createdAt, claimedAt, ...rest }) => rest);
|
|
30
|
+
}
|
|
31
|
+
async markDead(id) {
|
|
32
|
+
const inst = this.instances.find((i) => i.id === id);
|
|
33
|
+
if (inst)
|
|
34
|
+
inst.status = "dead";
|
|
35
|
+
}
|
|
36
|
+
async deleteDead() {
|
|
37
|
+
this.instances = this.instances.filter((i) => i.status !== "dead");
|
|
38
|
+
}
|
|
39
|
+
async claimWarm(tenantId, name) {
|
|
40
|
+
const warm = this.instances
|
|
41
|
+
.filter((i) => i.status === "warm")
|
|
42
|
+
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
43
|
+
if (warm.length === 0)
|
|
44
|
+
return null;
|
|
45
|
+
const target = warm[0];
|
|
46
|
+
target.status = "claimed";
|
|
47
|
+
target.tenantId = tenantId;
|
|
48
|
+
target.name = name;
|
|
49
|
+
target.claimedAt = new Date();
|
|
50
|
+
return { id: target.id, containerId: target.containerId };
|
|
51
|
+
}
|
|
52
|
+
async updateInstanceStatus(id, status) {
|
|
53
|
+
const inst = this.instances.find((i) => i.id === id);
|
|
54
|
+
if (inst)
|
|
55
|
+
inst.status = status;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for IPoolRepository interface contract.
|
|
3
|
+
*
|
|
4
|
+
* Uses an in-memory implementation to verify the interface
|
|
5
|
+
* behavior without requiring a real database.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { InMemoryPoolRepository } from "./in-memory-pool-repository.js";
|
|
9
|
+
describe("IPoolRepository (InMemory)", () => {
|
|
10
|
+
it("returns default pool size of 2", async () => {
|
|
11
|
+
const repo = new InMemoryPoolRepository();
|
|
12
|
+
expect(await repo.getPoolSize()).toBe(2);
|
|
13
|
+
});
|
|
14
|
+
it("sets and gets pool size", async () => {
|
|
15
|
+
const repo = new InMemoryPoolRepository();
|
|
16
|
+
await repo.setPoolSize(5);
|
|
17
|
+
expect(await repo.getPoolSize()).toBe(5);
|
|
18
|
+
});
|
|
19
|
+
it("inserts and counts warm instances", async () => {
|
|
20
|
+
const repo = new InMemoryPoolRepository();
|
|
21
|
+
expect(await repo.warmCount()).toBe(0);
|
|
22
|
+
await repo.insertWarm("a", "container-a");
|
|
23
|
+
await repo.insertWarm("b", "container-b");
|
|
24
|
+
expect(await repo.warmCount()).toBe(2);
|
|
25
|
+
});
|
|
26
|
+
it("lists warm instances", async () => {
|
|
27
|
+
const repo = new InMemoryPoolRepository();
|
|
28
|
+
await repo.insertWarm("a", "container-a");
|
|
29
|
+
await repo.insertWarm("b", "container-b");
|
|
30
|
+
const warm = await repo.listWarm();
|
|
31
|
+
expect(warm).toHaveLength(2);
|
|
32
|
+
expect(warm[0].id).toBe("a");
|
|
33
|
+
expect(warm[0].containerId).toBe("container-a");
|
|
34
|
+
expect(warm[0].status).toBe("warm");
|
|
35
|
+
});
|
|
36
|
+
it("claims warm instance FIFO", async () => {
|
|
37
|
+
const repo = new InMemoryPoolRepository();
|
|
38
|
+
await repo.insertWarm("first", "c-first");
|
|
39
|
+
await repo.insertWarm("second", "c-second");
|
|
40
|
+
const claimed = await repo.claimWarm("tenant-1", "my-bot");
|
|
41
|
+
expect(claimed).not.toBeNull();
|
|
42
|
+
expect(claimed?.id).toBe("first");
|
|
43
|
+
expect(claimed?.containerId).toBe("c-first");
|
|
44
|
+
// Warm count drops by 1
|
|
45
|
+
expect(await repo.warmCount()).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
it("returns null when claiming from empty pool", async () => {
|
|
48
|
+
const repo = new InMemoryPoolRepository();
|
|
49
|
+
const result = await repo.claimWarm("tenant-1", "bot");
|
|
50
|
+
expect(result).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
it("does not re-claim already claimed instances", async () => {
|
|
53
|
+
const repo = new InMemoryPoolRepository();
|
|
54
|
+
await repo.insertWarm("only", "c-only");
|
|
55
|
+
const first = await repo.claimWarm("t1", "bot1");
|
|
56
|
+
expect(first).not.toBeNull();
|
|
57
|
+
const second = await repo.claimWarm("t2", "bot2");
|
|
58
|
+
expect(second).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
it("marks instances dead", async () => {
|
|
61
|
+
const repo = new InMemoryPoolRepository();
|
|
62
|
+
await repo.insertWarm("a", "c-a");
|
|
63
|
+
await repo.markDead("a");
|
|
64
|
+
expect(await repo.warmCount()).toBe(0);
|
|
65
|
+
const warm = await repo.listWarm();
|
|
66
|
+
expect(warm).toHaveLength(0);
|
|
67
|
+
});
|
|
68
|
+
it("deletes dead instances", async () => {
|
|
69
|
+
const repo = new InMemoryPoolRepository();
|
|
70
|
+
await repo.insertWarm("a", "c-a");
|
|
71
|
+
await repo.insertWarm("b", "c-b");
|
|
72
|
+
await repo.markDead("a");
|
|
73
|
+
await repo.deleteDead();
|
|
74
|
+
// Only 'b' remains (warm)
|
|
75
|
+
expect(await repo.warmCount()).toBe(1);
|
|
76
|
+
const warm = await repo.listWarm();
|
|
77
|
+
expect(warm[0].id).toBe("b");
|
|
78
|
+
});
|
|
79
|
+
it("updates instance status", async () => {
|
|
80
|
+
const repo = new InMemoryPoolRepository();
|
|
81
|
+
await repo.insertWarm("a", "c-a");
|
|
82
|
+
await repo.updateInstanceStatus("a", "provisioning");
|
|
83
|
+
// No longer warm
|
|
84
|
+
expect(await repo.warmCount()).toBe(0);
|
|
85
|
+
});
|
|
86
|
+
it("handles multiple claims in order", async () => {
|
|
87
|
+
const repo = new InMemoryPoolRepository();
|
|
88
|
+
await repo.insertWarm("1", "c-1");
|
|
89
|
+
await repo.insertWarm("2", "c-2");
|
|
90
|
+
await repo.insertWarm("3", "c-3");
|
|
91
|
+
const c1 = await repo.claimWarm("t-a", "bot-a");
|
|
92
|
+
const c2 = await repo.claimWarm("t-b", "bot-b");
|
|
93
|
+
const c3 = await repo.claimWarm("t-c", "bot-c");
|
|
94
|
+
const c4 = await repo.claimWarm("t-d", "bot-d");
|
|
95
|
+
expect(c1?.id).toBe("1");
|
|
96
|
+
expect(c2?.id).toBe("2");
|
|
97
|
+
expect(c3?.id).toBe("3");
|
|
98
|
+
expect(c4).toBeNull();
|
|
99
|
+
expect(await repo.warmCount()).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
it("dead instances are not claimable", async () => {
|
|
102
|
+
const repo = new InMemoryPoolRepository();
|
|
103
|
+
await repo.insertWarm("a", "c-a");
|
|
104
|
+
await repo.markDead("a");
|
|
105
|
+
const result = await repo.claimWarm("t1", "bot");
|
|
106
|
+
expect(result).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
});
|
package/package.json
CHANGED
|
@@ -379,10 +379,11 @@ describe("ProxyManager", () => {
|
|
|
379
379
|
});
|
|
380
380
|
|
|
381
381
|
describe("start rollback on failure", () => {
|
|
382
|
-
it("calls stop() if reload fails during start()", async () => {
|
|
382
|
+
it("calls stop() and continues if reload fails during start()", async () => {
|
|
383
383
|
vi.mocked(fetch).mockRejectedValueOnce(new Error("connection refused"));
|
|
384
384
|
|
|
385
|
-
|
|
385
|
+
// start() should NOT throw — proxy failure is non-fatal
|
|
386
|
+
await expect(manager.start()).resolves.toBeUndefined();
|
|
386
387
|
expect(manager.isRunning).toBe(false);
|
|
387
388
|
});
|
|
388
389
|
});
|
package/src/proxy/manager.ts
CHANGED
|
@@ -230,8 +230,8 @@ export class ProxyManager implements ProxyManagerInterface {
|
|
|
230
230
|
try {
|
|
231
231
|
await this.reload();
|
|
232
232
|
} catch (err) {
|
|
233
|
+
logger.warn("Proxy manager failed to connect to Caddy — continuing without proxy management", { err });
|
|
233
234
|
await this.stop();
|
|
234
|
-
throw err;
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
|
|
@@ -37,9 +37,20 @@ export interface BootConfig {
|
|
|
37
37
|
/** Short product identifier (e.g. "paperclip", "wopr", "holyship"). */
|
|
38
38
|
slug: string;
|
|
39
39
|
|
|
40
|
-
/** PostgreSQL connection string. */
|
|
40
|
+
/** PostgreSQL connection string. Required unless `pool` is provided. */
|
|
41
41
|
databaseUrl: string;
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Pre-created PostgreSQL connection pool. When provided, buildContainer
|
|
45
|
+
* reuses this pool and skips pool creation + Drizzle migrations. The
|
|
46
|
+
* caller is responsible for running their own migrations before calling
|
|
47
|
+
* buildContainer.
|
|
48
|
+
*
|
|
49
|
+
* Use this when the product has its own migration set (e.g. wopr-platform
|
|
50
|
+
* generates migrations locally from the shared schema).
|
|
51
|
+
*/
|
|
52
|
+
pool?: import("pg").Pool;
|
|
53
|
+
|
|
43
54
|
/** Bind host (default "0.0.0.0"). */
|
|
44
55
|
host?: string;
|
|
45
56
|
|
package/src/server/container.ts
CHANGED
|
@@ -110,23 +110,32 @@ export interface PlatformContainer {
|
|
|
110
110
|
* `bootConfig.features`. Disabled features yield `null`.
|
|
111
111
|
*/
|
|
112
112
|
export async function buildContainer(bootConfig: BootConfig): Promise<PlatformContainer> {
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
// 1. Database pool — reuse existing or create new
|
|
114
|
+
let pool: Pool;
|
|
115
|
+
if (bootConfig.pool) {
|
|
116
|
+
pool = bootConfig.pool;
|
|
117
|
+
} else {
|
|
118
|
+
if (!bootConfig.databaseUrl) {
|
|
119
|
+
throw new Error("buildContainer: databaseUrl is required when pool is not provided");
|
|
120
|
+
}
|
|
121
|
+
const { Pool: PgPool } = await import("pg");
|
|
122
|
+
pool = new PgPool({ connectionString: bootConfig.databaseUrl });
|
|
115
123
|
}
|
|
116
124
|
|
|
117
|
-
// 1. Database pool
|
|
118
|
-
const { Pool: PgPool } = await import("pg");
|
|
119
|
-
const pool: Pool = new PgPool({ connectionString: bootConfig.databaseUrl });
|
|
120
|
-
|
|
121
125
|
// 2. Drizzle ORM instance
|
|
122
126
|
const { createDb } = await import("../db/index.js");
|
|
123
127
|
const db = createDb(pool);
|
|
124
128
|
|
|
125
|
-
// 3. Run Drizzle migrations
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
// 3. Run Drizzle migrations (skip when caller provided their own pool —
|
|
130
|
+
// they are responsible for running product-specific migrations first)
|
|
131
|
+
if (!bootConfig.pool) {
|
|
132
|
+
const { migrate } = await import("drizzle-orm/node-postgres/migrator");
|
|
133
|
+
const path = await import("node:path");
|
|
134
|
+
const { fileURLToPath } = await import("node:url");
|
|
135
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
136
|
+
const migrationsFolder = path.resolve(__dirname, "..", "..", "drizzle", "migrations");
|
|
137
|
+
await migrate(db as never, { migrationsFolder });
|
|
138
|
+
}
|
|
130
139
|
|
|
131
140
|
// 4. Bootstrap product config from DB (auto-seeds from presets if needed)
|
|
132
141
|
const { platformBoot } = await import("../product-config/boot.js");
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for hot pool service functions (getPoolSize, setPoolSize).
|
|
3
|
+
*
|
|
4
|
+
* Uses InMemoryPoolRepository to test service logic without Docker or DB.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { getPoolSize, setPoolSize } from "../hot-pool.js";
|
|
9
|
+
import { InMemoryPoolRepository } from "./in-memory-pool-repository.js";
|
|
10
|
+
|
|
11
|
+
describe("hot pool service", () => {
|
|
12
|
+
describe("getPoolSize / setPoolSize", () => {
|
|
13
|
+
it("returns default pool size", async () => {
|
|
14
|
+
const repo = new InMemoryPoolRepository();
|
|
15
|
+
expect(await getPoolSize(repo)).toBe(2);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("sets and reads pool size", async () => {
|
|
19
|
+
const repo = new InMemoryPoolRepository();
|
|
20
|
+
await setPoolSize(repo, 10);
|
|
21
|
+
expect(await getPoolSize(repo)).toBe(10);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("pool size of 0 is valid", async () => {
|
|
25
|
+
const repo = new InMemoryPoolRepository();
|
|
26
|
+
await setPoolSize(repo, 0);
|
|
27
|
+
expect(await getPoolSize(repo)).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory IPoolRepository for testing.
|
|
3
|
+
* FIFO claiming, dead instance handling — no DB required.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { IPoolRepository, PoolInstance } from "../pool-repository.js";
|
|
7
|
+
|
|
8
|
+
export class InMemoryPoolRepository implements IPoolRepository {
|
|
9
|
+
private poolSize = 2;
|
|
10
|
+
private instances: Array<PoolInstance & { createdAt: Date; claimedAt: Date | null }> = [];
|
|
11
|
+
|
|
12
|
+
async getPoolSize(): Promise<number> {
|
|
13
|
+
return this.poolSize;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async setPoolSize(size: number): Promise<void> {
|
|
17
|
+
this.poolSize = size;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async warmCount(): Promise<number> {
|
|
21
|
+
return this.instances.filter((i) => i.status === "warm").length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async insertWarm(id: string, containerId: string): Promise<void> {
|
|
25
|
+
this.instances.push({
|
|
26
|
+
id,
|
|
27
|
+
containerId,
|
|
28
|
+
status: "warm",
|
|
29
|
+
tenantId: null,
|
|
30
|
+
name: null,
|
|
31
|
+
createdAt: new Date(),
|
|
32
|
+
claimedAt: null,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async listWarm(): Promise<PoolInstance[]> {
|
|
37
|
+
return this.instances.filter((i) => i.status === "warm").map(({ createdAt, claimedAt, ...rest }) => rest);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async markDead(id: string): Promise<void> {
|
|
41
|
+
const inst = this.instances.find((i) => i.id === id);
|
|
42
|
+
if (inst) inst.status = "dead";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async deleteDead(): Promise<void> {
|
|
46
|
+
this.instances = this.instances.filter((i) => i.status !== "dead");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async claimWarm(tenantId: string, name: string): Promise<{ id: string; containerId: string } | null> {
|
|
50
|
+
const warm = this.instances
|
|
51
|
+
.filter((i) => i.status === "warm")
|
|
52
|
+
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
53
|
+
if (warm.length === 0) return null;
|
|
54
|
+
const target = warm[0];
|
|
55
|
+
target.status = "claimed";
|
|
56
|
+
target.tenantId = tenantId;
|
|
57
|
+
target.name = name;
|
|
58
|
+
target.claimedAt = new Date();
|
|
59
|
+
return { id: target.id, containerId: target.containerId };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async updateInstanceStatus(id: string, status: string): Promise<void> {
|
|
63
|
+
const inst = this.instances.find((i) => i.id === id);
|
|
64
|
+
if (inst) inst.status = status;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for IPoolRepository interface contract.
|
|
3
|
+
*
|
|
4
|
+
* Uses an in-memory implementation to verify the interface
|
|
5
|
+
* behavior without requiring a real database.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { InMemoryPoolRepository } from "./in-memory-pool-repository.js";
|
|
10
|
+
|
|
11
|
+
describe("IPoolRepository (InMemory)", () => {
|
|
12
|
+
it("returns default pool size of 2", async () => {
|
|
13
|
+
const repo = new InMemoryPoolRepository();
|
|
14
|
+
expect(await repo.getPoolSize()).toBe(2);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("sets and gets pool size", async () => {
|
|
18
|
+
const repo = new InMemoryPoolRepository();
|
|
19
|
+
await repo.setPoolSize(5);
|
|
20
|
+
expect(await repo.getPoolSize()).toBe(5);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("inserts and counts warm instances", async () => {
|
|
24
|
+
const repo = new InMemoryPoolRepository();
|
|
25
|
+
expect(await repo.warmCount()).toBe(0);
|
|
26
|
+
|
|
27
|
+
await repo.insertWarm("a", "container-a");
|
|
28
|
+
await repo.insertWarm("b", "container-b");
|
|
29
|
+
|
|
30
|
+
expect(await repo.warmCount()).toBe(2);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("lists warm instances", async () => {
|
|
34
|
+
const repo = new InMemoryPoolRepository();
|
|
35
|
+
await repo.insertWarm("a", "container-a");
|
|
36
|
+
await repo.insertWarm("b", "container-b");
|
|
37
|
+
|
|
38
|
+
const warm = await repo.listWarm();
|
|
39
|
+
expect(warm).toHaveLength(2);
|
|
40
|
+
expect(warm[0].id).toBe("a");
|
|
41
|
+
expect(warm[0].containerId).toBe("container-a");
|
|
42
|
+
expect(warm[0].status).toBe("warm");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("claims warm instance FIFO", async () => {
|
|
46
|
+
const repo = new InMemoryPoolRepository();
|
|
47
|
+
await repo.insertWarm("first", "c-first");
|
|
48
|
+
await repo.insertWarm("second", "c-second");
|
|
49
|
+
|
|
50
|
+
const claimed = await repo.claimWarm("tenant-1", "my-bot");
|
|
51
|
+
expect(claimed).not.toBeNull();
|
|
52
|
+
expect(claimed?.id).toBe("first");
|
|
53
|
+
expect(claimed?.containerId).toBe("c-first");
|
|
54
|
+
|
|
55
|
+
// Warm count drops by 1
|
|
56
|
+
expect(await repo.warmCount()).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns null when claiming from empty pool", async () => {
|
|
60
|
+
const repo = new InMemoryPoolRepository();
|
|
61
|
+
const result = await repo.claimWarm("tenant-1", "bot");
|
|
62
|
+
expect(result).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not re-claim already claimed instances", async () => {
|
|
66
|
+
const repo = new InMemoryPoolRepository();
|
|
67
|
+
await repo.insertWarm("only", "c-only");
|
|
68
|
+
|
|
69
|
+
const first = await repo.claimWarm("t1", "bot1");
|
|
70
|
+
expect(first).not.toBeNull();
|
|
71
|
+
|
|
72
|
+
const second = await repo.claimWarm("t2", "bot2");
|
|
73
|
+
expect(second).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("marks instances dead", async () => {
|
|
77
|
+
const repo = new InMemoryPoolRepository();
|
|
78
|
+
await repo.insertWarm("a", "c-a");
|
|
79
|
+
await repo.markDead("a");
|
|
80
|
+
|
|
81
|
+
expect(await repo.warmCount()).toBe(0);
|
|
82
|
+
const warm = await repo.listWarm();
|
|
83
|
+
expect(warm).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("deletes dead instances", async () => {
|
|
87
|
+
const repo = new InMemoryPoolRepository();
|
|
88
|
+
await repo.insertWarm("a", "c-a");
|
|
89
|
+
await repo.insertWarm("b", "c-b");
|
|
90
|
+
await repo.markDead("a");
|
|
91
|
+
|
|
92
|
+
await repo.deleteDead();
|
|
93
|
+
|
|
94
|
+
// Only 'b' remains (warm)
|
|
95
|
+
expect(await repo.warmCount()).toBe(1);
|
|
96
|
+
const warm = await repo.listWarm();
|
|
97
|
+
expect(warm[0].id).toBe("b");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("updates instance status", async () => {
|
|
101
|
+
const repo = new InMemoryPoolRepository();
|
|
102
|
+
await repo.insertWarm("a", "c-a");
|
|
103
|
+
await repo.updateInstanceStatus("a", "provisioning");
|
|
104
|
+
|
|
105
|
+
// No longer warm
|
|
106
|
+
expect(await repo.warmCount()).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("handles multiple claims in order", async () => {
|
|
110
|
+
const repo = new InMemoryPoolRepository();
|
|
111
|
+
await repo.insertWarm("1", "c-1");
|
|
112
|
+
await repo.insertWarm("2", "c-2");
|
|
113
|
+
await repo.insertWarm("3", "c-3");
|
|
114
|
+
|
|
115
|
+
const c1 = await repo.claimWarm("t-a", "bot-a");
|
|
116
|
+
const c2 = await repo.claimWarm("t-b", "bot-b");
|
|
117
|
+
const c3 = await repo.claimWarm("t-c", "bot-c");
|
|
118
|
+
const c4 = await repo.claimWarm("t-d", "bot-d");
|
|
119
|
+
|
|
120
|
+
expect(c1?.id).toBe("1");
|
|
121
|
+
expect(c2?.id).toBe("2");
|
|
122
|
+
expect(c3?.id).toBe("3");
|
|
123
|
+
expect(c4).toBeNull();
|
|
124
|
+
expect(await repo.warmCount()).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("dead instances are not claimable", async () => {
|
|
128
|
+
const repo = new InMemoryPoolRepository();
|
|
129
|
+
await repo.insertWarm("a", "c-a");
|
|
130
|
+
await repo.markDead("a");
|
|
131
|
+
|
|
132
|
+
const result = await repo.claimWarm("t1", "bot");
|
|
133
|
+
expect(result).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
});
|