Skip to content

Commit 30cbce2

Browse files
committed
refactor: unified @constructive-io/module-loader package
- Create packages/module-loader/ with TtlCache, ComputeModuleLoader, UsageLoader, BillingLoader, and unified ModuleLoader facade - Multi-database support: all loaders accept databaseId for platform vs tenant - Add GraphExecutionModuleConfig to resolve node_states, complete_node, fail_node table/function names from MetaSchema - Remove hardcoded constructive_compute_public/private refs from compute-worker graph operations (markNodeRunning, completeGraphNode, failGraphExecution) - Migrate compute-worker to import from shared package (cache, module-loader, billing, compute-log, discovery, invocation all delegate to module-loader) - usage-loader is now a thin re-export from module-loader for backward compat - 119 integration + 25 unit tests passing
1 parent bfc8e5c commit 30cbce2

21 files changed

Lines changed: 1072 additions & 718 deletions

job/compute-worker/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"dependencies": {
1515
"@constructive-io/job-pg": "^2.5.4",
1616
"@constructive-io/job-utils": "^2.5.4",
17+
"@constructive-io/module-loader": "workspace:^",
1718
"@pgpmjs/logger": "^2.4.3",
1819
"pg": "8.20.0"
1920
},

job/compute-worker/src/billing.ts

Lines changed: 5 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,13 @@
11
/**
2-
* BillingTracker — quota checks and usage recording via the billing_module.
3-
*
4-
* Discovers billing configuration from metaschema_modules_public.billing_module.
5-
* Gracefully no-ops when billing is not provisioned (standalone dev mode).
2+
* BillingTracker — re-exports BillingLoader from @constructive-io/module-loader
3+
* under the legacy name `BillingTracker`.
64
*/
75

8-
import { Logger } from '@pgpmjs/logger';
6+
import { BillingLoader } from '@constructive-io/module-loader';
97
import type { Pool } from 'pg';
108

11-
import { TtlCache } from './cache';
12-
import type { BillingModuleConfig } from './types';
13-
14-
const log = new Logger('compute:billing');
15-
16-
const BILLING_MODULE_SQL = `
17-
SELECT
18-
s.schema_name AS public_schema,
19-
ps.schema_name AS private_schema,
20-
bm.record_usage_function
21-
FROM metaschema_modules_public.billing_module bm
22-
JOIN metaschema_public.schema s ON bm.schema_id = s.id
23-
JOIN metaschema_public.schema ps ON bm.private_schema_id = ps.id
24-
WHERE bm.database_id = $1
25-
LIMIT 1
26-
`;
27-
28-
export class BillingTracker {
29-
private pool: Pool;
30-
private cache: TtlCache<BillingModuleConfig | null>;
31-
private databaseId: string;
32-
9+
export class BillingTracker extends BillingLoader {
3310
constructor(pool: Pool, databaseId: string, cacheTtlMs?: number) {
34-
this.pool = pool;
35-
this.databaseId = databaseId;
36-
this.cache = new TtlCache<BillingModuleConfig | null>(cacheTtlMs ?? 60_000);
37-
}
38-
39-
async load(databaseId?: string): Promise<BillingModuleConfig | null> {
40-
const dbId = databaseId ?? this.databaseId;
41-
const cached = this.cache.get(dbId);
42-
if (cached !== undefined) return cached;
43-
44-
try {
45-
const { rows } = await this.pool.query(BILLING_MODULE_SQL, [dbId]);
46-
if (!rows.length || !rows[0].record_usage_function) {
47-
this.cache.set(dbId, null);
48-
return null;
49-
}
50-
const config: BillingModuleConfig = {
51-
publicSchema: rows[0].public_schema,
52-
privateSchema: rows[0].private_schema,
53-
recordUsageFunction: rows[0].record_usage_function,
54-
};
55-
log.debug(`loaded billing module: ${config.privateSchema}.${config.recordUsageFunction}`);
56-
this.cache.set(dbId, config);
57-
return config;
58-
} catch {
59-
this.cache.set(dbId, null);
60-
return null;
61-
}
62-
}
63-
64-
/**
65-
* Check if the entity has quota for this meter.
66-
* Returns true if billing is not provisioned (graceful degradation).
67-
*/
68-
async checkQuota(
69-
entityId: string,
70-
meterSlug: string,
71-
amount: number = 1,
72-
databaseId?: string
73-
): Promise<boolean> {
74-
const config = await this.load(databaseId);
75-
if (!config) return true;
76-
77-
try {
78-
const sql = `SELECT "${config.privateSchema}"."check_billing_quota"($1, $2::uuid, $3) AS allowed`;
79-
const { rows } = await this.pool.query(sql, [meterSlug, entityId, amount]);
80-
return rows[0]?.allowed !== false;
81-
} catch (err: any) {
82-
log.warn(`check_billing_quota failed (allowing): ${err.message}`);
83-
return true;
84-
}
85-
}
86-
87-
/**
88-
* Record usage after a function completes.
89-
* No-ops if billing is not provisioned.
90-
*/
91-
async recordUsage(
92-
entityId: string,
93-
meterSlug: string,
94-
amount: number,
95-
metadata: Record<string, unknown>,
96-
databaseId?: string
97-
): Promise<void> {
98-
const config = await this.load(databaseId);
99-
if (!config) return;
100-
101-
try {
102-
const sql = `SELECT "${config.privateSchema}"."${config.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`;
103-
await this.pool.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata)]);
104-
log.debug(`recorded usage: ${meterSlug} entity=${entityId} amount=${amount}`);
105-
} catch (err: any) {
106-
log.warn(`record_usage failed (non-fatal): ${err.message}`);
107-
}
11+
super(pool, databaseId, cacheTtlMs);
10812
}
10913
}

job/compute-worker/src/cache.ts

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,4 @@
11
/**
2-
* Simple TTL cache — same pattern as agentic-server.
3-
* Entries expire after the configured TTL.
2+
* Re-export TtlCache from @constructive-io/module-loader.
43
*/
5-
6-
interface CacheEntry<T> {
7-
value: T;
8-
expires_at: number;
9-
}
10-
11-
export class TtlCache<T> {
12-
private store = new Map<string, CacheEntry<T>>();
13-
private ttl_ms: number;
14-
15-
constructor(ttl_ms: number) {
16-
this.ttl_ms = ttl_ms;
17-
}
18-
19-
get(key: string): T | undefined {
20-
const entry = this.store.get(key);
21-
if (!entry) return undefined;
22-
if (Date.now() > entry.expires_at) {
23-
this.store.delete(key);
24-
return undefined;
25-
}
26-
return entry.value;
27-
}
28-
29-
set(key: string, value: T): void {
30-
this.store.set(key, { value, expires_at: Date.now() + this.ttl_ms });
31-
}
32-
33-
delete(key: string): void {
34-
this.store.delete(key);
35-
}
36-
37-
clear(): void {
38-
this.store.clear();
39-
}
40-
41-
get size(): number {
42-
return this.store.size;
43-
}
44-
}
4+
export { TtlCache } from '@constructive-io/module-loader';

job/compute-worker/src/compute-log.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@
66
* Gracefully no-ops if compute_log_module is not registered.
77
*/
88

9+
import type { ComputeModuleLoader } from '@constructive-io/module-loader';
910
import { Logger } from '@pgpmjs/logger';
1011
import type { Pool } from 'pg';
1112

12-
import { ComputeModuleLoader } from './module-loader';
13-
1413
const log = new Logger('compute:log-tracker');
1514

1615
export interface ComputeLogEntry {

job/compute-worker/src/discovery.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
* fetches the function definition and caches it for `ttlMs` (default 60 s).
99
*/
1010

11+
import { TtlCache } from '@constructive-io/module-loader';
12+
import type { ComputeModuleLoader } from '@constructive-io/module-loader';
1113
import { Logger } from '@pgpmjs/logger';
1214
import type { Pool } from 'pg';
1315

14-
import { TtlCache } from './cache';
15-
import type { ComputeModuleLoader } from './module-loader';
1616
import type { PlatformFunctionDefinition } from './types';
1717

1818
const log = new Logger('compute:discovery');

job/compute-worker/src/index.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import poolManager from '@constructive-io/job-pg';
1616
import type { PgClientLike } from '@constructive-io/job-utils';
1717
import * as jobs from '@constructive-io/job-utils';
18+
import { ComputeModuleLoader } from '@constructive-io/module-loader';
19+
import type { GraphExecutionModuleConfig } from '@constructive-io/module-loader';
1820
import { Logger } from '@pgpmjs/logger';
1921
import type { Pool, PoolClient } from 'pg';
2022

@@ -23,7 +25,6 @@ import { ComputeLogTracker } from './compute-log';
2325
import { FunctionDiscovery } from './discovery';
2426
import { executeInline, getInlineImpl } from './inline';
2527
import { InvocationTracker } from './invocation';
26-
import { ComputeModuleLoader } from './module-loader';
2728
import { compute_request } from './req';
2829
import type { ComputeJobRow, ComputeWorkerOptions, PlatformFunctionDefinition } from './types';
2930
import { isGraphNodePayload } from './types';
@@ -53,6 +54,7 @@ export type {
5354
FunctionPortDefinition,
5455
FunctionRequirement,
5556
FunctionRuntime,
57+
GraphExecutionModuleConfig,
5658
GraphNodePayload,
5759
InvocationModuleConfig,
5860
InvocationStatus,
@@ -622,6 +624,14 @@ export default class ComputeWorker {
622624
}
623625
}
624626

627+
/**
628+
* Resolve graph execution module config (cached via ComputeModuleLoader).
629+
*/
630+
private async graphConfig(): Promise<GraphExecutionModuleConfig> {
631+
const config = await this.loader.load(this.platformDatabaseId);
632+
return config.graphExecutionModule;
633+
}
634+
625635
/**
626636
* Transition a node from queued → running when the worker picks up the job.
627637
*/
@@ -630,8 +640,9 @@ export default class ComputeWorker {
630640
nodeName: string
631641
): Promise<void> {
632642
log.debug('marking graph node running', { executionId, nodeName });
643+
const ge = await this.graphConfig();
633644
await this.pgPool.query(
634-
`UPDATE constructive_compute_public.platform_function_graph_execution_node_states
645+
`UPDATE "${ge.publicSchema}"."${ge.nodeStatesTable}"
635646
SET status = 'running', started_at = now()
636647
WHERE execution_id = $1::uuid AND node_name = $2 AND status = 'queued'`,
637648
[executionId, nodeName]
@@ -649,8 +660,9 @@ export default class ComputeWorker {
649660
output: unknown
650661
): Promise<void> {
651662
log.debug('completing graph node', { executionId, nodeName });
663+
const ge = await this.graphConfig();
652664
await this.pgPool.query(
653-
`SELECT constructive_compute_private.platform_complete_node($1::uuid, $2, $3::jsonb)`,
665+
`SELECT "${ge.privateSchema}"."${ge.completeNodeFunction}"($1::uuid, $2, $3::jsonb)`,
654666
[executionId, nodeName, JSON.stringify(output ?? {})]
655667
);
656668
}
@@ -667,13 +679,12 @@ export default class ComputeWorker {
667679
): Promise<void> {
668680
log.error('graph node failed', { executionId, nodeName, error: errorMessage });
669681
try {
682+
const ge = await this.graphConfig();
670683
await this.pgPool.query(
671-
`SELECT constructive_compute_private.platform_fail_node($1::uuid, $2, $3, $4)`,
684+
`SELECT "${ge.privateSchema}"."${ge.failNodeFunction}"($1::uuid, $2, $3, $4)`,
672685
[executionId, nodeName, 'NODE_EXECUTION_FAILED', errorMessage]
673686
);
674687
} catch (err: any) {
675-
// Execution may already be completed/failed (race with graphOutput).
676-
// Log so the error is never invisible.
677688
log.warn('platform_fail_node raised; execution may already be finished', {
678689
executionId, nodeName, error: errorMessage, sqlError: err.message,
679690
});

job/compute-worker/src/invocation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
* 3. `fail()` — updates to status='failed' with error + duration
1515
*/
1616

17+
import type { ComputeModuleLoader, InvocationModuleConfig } from '@constructive-io/module-loader';
1718
import { Logger } from '@pgpmjs/logger';
1819
import type { Pool } from 'pg';
1920

20-
import type { ComputeModuleLoader } from './module-loader';
21-
import type { CreateInvocationInput, InvocationModuleConfig } from './types';
21+
import type { CreateInvocationInput } from './types';
2222

2323
const log = new Logger('compute:invocation');
2424

0 commit comments

Comments
 (0)