@republicroad/zen-udf 0.2.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/LICENSE +13 -0
- package/README.md +80 -0
- package/docs/naming.md +40 -0
- package/package.json +24 -0
- package/src/context-hardening.test.ts +93 -0
- package/src/contrib/crypto.test.ts +60 -0
- package/src/contrib/crypto.ts +74 -0
- package/src/contrib/custom-list-query.ts +51 -0
- package/src/contrib/debug.ts +42 -0
- package/src/contrib/debugui.test.ts +10 -0
- package/src/contrib/debugui.ts +27 -0
- package/src/contrib/http-guard.test.ts +116 -0
- package/src/contrib/http.test.ts +230 -0
- package/src/contrib/http.ts +283 -0
- package/src/contrib/ip-location.ts +82 -0
- package/src/contrib/legacy-graph-acceptance.test.ts +67 -0
- package/src/contrib/rate-store-conformance.ts +68 -0
- package/src/contrib/rate-store.test.ts +30 -0
- package/src/contrib/rate-window.ts +195 -0
- package/src/contrib/rebuilt-functions.test.ts +60 -0
- package/src/contrib/roster.test.ts +27 -0
- package/src/contrib/roster.ts +38 -0
- package/src/decision-cache.test.ts +122 -0
- package/src/decision-cache.ts +104 -0
- package/src/decision-runtime.test.ts +154 -0
- package/src/engine-cache-semantics.test.ts +87 -0
- package/src/engine.ts +474 -0
- package/src/exec-context.test.ts +52 -0
- package/src/exec-context.ts +32 -0
- package/src/execution-spec.test.ts +173 -0
- package/src/index.ts +43 -0
- package/src/limiter.ts +70 -0
- package/src/reference.ts +34 -0
- package/src/register.test.ts +60 -0
- package/src/register.ts +515 -0
- package/src/roster.test.ts +96 -0
- package/src/roster.ts +129 -0
- package/src/sanitize.test.ts +65 -0
- package/src/udf-pack.test.ts +107 -0
- package/src/udf-trace.test.ts +137 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { RateStore } from './rate-window.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RateStore 契约测试(U8):任何 RateStore 实现(含 verdict 侧 Redis 实现)都应
|
|
7
|
+
* 用本套件验证同一行为语义。工厂接收可注入时钟 now()——实现应使用该时钟打点
|
|
8
|
+
* (Redis 实现可将 now() 的时间戳随命令传入,而非依赖服务器时钟)。
|
|
9
|
+
*/
|
|
10
|
+
export const rateStoreConformance = (name: string, createStore: (now: () => number) => RateStore): void => {
|
|
11
|
+
describe(`RateStore conformance: ${name}`, () => {
|
|
12
|
+
let nowMs = 1_700_000_000_000;
|
|
13
|
+
let store: RateStore;
|
|
14
|
+
|
|
15
|
+
const advance = (ms: number): void => {
|
|
16
|
+
nowMs += ms;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
test('rate:首次计数 1、递增、实体间独立', async () => {
|
|
20
|
+
store = createStore(() => nowMs);
|
|
21
|
+
const a1 = await store.rate('ip-a', 3_600_000);
|
|
22
|
+
const a2 = await store.rate('ip-a', 3_600_000);
|
|
23
|
+
const b1 = await store.rate('ip-b', 3_600_000);
|
|
24
|
+
expect([a1.counter, a2.counter, b1.counter]).toEqual([1, 2, 1]);
|
|
25
|
+
expect(a1.v).toBe('ip-a');
|
|
26
|
+
expect(a1.idle).toBe(0);
|
|
27
|
+
expect(typeof a1.timestamp).toBe('string');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('rate:窗口滑出后计数重置,idle 为距上次事件秒数', async () => {
|
|
31
|
+
nowMs = 1_700_000_000_000;
|
|
32
|
+
store = createStore(() => nowMs);
|
|
33
|
+
await store.rate('ip-w', 60_000);
|
|
34
|
+
advance(30_000);
|
|
35
|
+
const second = await store.rate('ip-w', 60_000);
|
|
36
|
+
expect(second.counter).toBe(2);
|
|
37
|
+
expect(second.idle).toBe(30);
|
|
38
|
+
advance(61_000); // 两次事件均滑出 60s 窗口
|
|
39
|
+
const third = await store.rate('ip-w', 60_000);
|
|
40
|
+
expect(third.counter).toBe(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('groupDistinct:pv 累计、uv 按值去重、组间独立', async () => {
|
|
44
|
+
nowMs = 1_800_000_000_000;
|
|
45
|
+
store = createStore(() => nowMs);
|
|
46
|
+
const r1 = await store.groupDistinct('g-ip', 'p1', 3_600_000);
|
|
47
|
+
const r2 = await store.groupDistinct('g-ip', 'p1', 3_600_000);
|
|
48
|
+
const r3 = await store.groupDistinct('g-ip', 'p2', 3_600_000);
|
|
49
|
+
const other = await store.groupDistinct('g-other', 'p1', 3_600_000);
|
|
50
|
+
expect([r1.pv, r1.uv]).toEqual([1, 1]);
|
|
51
|
+
expect([r2.pv, r2.uv]).toEqual([2, 1]);
|
|
52
|
+
expect([r3.pv, r3.uv]).toEqual([3, 2]);
|
|
53
|
+
expect(other.pv).toBe(1);
|
|
54
|
+
expect(other.group).toBe('g-other');
|
|
55
|
+
expect(other.v).toBe('p1');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('groupDistinct:窗口滑出后 pv/uv 重置', async () => {
|
|
59
|
+
nowMs = 1_900_000_000_000;
|
|
60
|
+
store = createStore(() => nowMs);
|
|
61
|
+
await store.groupDistinct('g-w', 'v1', 60_000);
|
|
62
|
+
await store.groupDistinct('g-w', 'v2', 60_000);
|
|
63
|
+
advance(61_000);
|
|
64
|
+
const after = await store.groupDistinct('g-w', 'v3', 60_000);
|
|
65
|
+
expect([after.pv, after.uv]).toEqual([1, 1]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { rateStoreConformance } from './rate-store-conformance.ts';
|
|
4
|
+
import { InMemoryRateStore, __resetRateWindows, setRateStore } from './rate-window.ts';
|
|
5
|
+
|
|
6
|
+
// 参考实现(InMemoryRateStore)必须通过契约测试——verdict 的 Redis 实现
|
|
7
|
+
// 复用 rateStoreConformance 验证同一语义(宿主裁决 D1)
|
|
8
|
+
rateStoreConformance('InMemoryRateStore', (now) => new InMemoryRateStore(now));
|
|
9
|
+
|
|
10
|
+
describe('RateStore 注入点', () => {
|
|
11
|
+
test('setRateStore 后 UDF 语义经注入实现执行;__resetRateWindows 调用 reset', async () => {
|
|
12
|
+
const calls: string[] = [];
|
|
13
|
+
setRateStore({
|
|
14
|
+
rate: (entity) => {
|
|
15
|
+
calls.push('rate:' + entity);
|
|
16
|
+
return { counter: 42, v: entity, idle: 0, timestamp: 'injected' };
|
|
17
|
+
},
|
|
18
|
+
groupDistinct: (group, value) => {
|
|
19
|
+
calls.push('group:' + group);
|
|
20
|
+
return { idle: 0, pv: 0, uv: 0, gidle: 0, vidle: 0, group, v: value, timestamp: 'injected' };
|
|
21
|
+
},
|
|
22
|
+
reset: () => calls.push('reset'),
|
|
23
|
+
});
|
|
24
|
+
__resetRateWindows();
|
|
25
|
+
expect(calls).toContain('reset');
|
|
26
|
+
// 还原缺省实现,避免影响其它测试
|
|
27
|
+
setRateStore(new InMemoryRateStore());
|
|
28
|
+
__resetRateWindows();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { defineContrib, defineTool } from '../register.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 旧平台函数域重建(第六十九批 D2,docs/13 §8.3):滑动窗口频控。
|
|
5
|
+
* U8 起拆分为 RateStore 端口 + 进程内参考实现:
|
|
6
|
+
* - 端口(RateStore)是机制契约;verdict 侧提供 Redis 真实实现(宿主裁决 D1),
|
|
7
|
+
* 并复用 rate-store-conformance.ts 的同一套契约测试。
|
|
8
|
+
* - InMemoryRateStore 为单实例语义的开发态参考实现,仅测试/本地使用。
|
|
9
|
+
*
|
|
10
|
+
* 字段语义按图内 returnSchema 重建(撞库攻击防御.json):
|
|
11
|
+
* - rate(entity, window) → RateCommonResult {counter 窗口内事件数, v 实体, idle 距上次事件秒数, timestamp}
|
|
12
|
+
* - groupDistinct(group, value, window) → GroupDistinctCommonResult
|
|
13
|
+
* {pv 组窗口内事件数, uv 组窗口内去重值数, idle 距该 (group,value) 对上次事件秒数,
|
|
14
|
+
* gidle 距该组上次事件秒数, vidle 距该值上次事件秒数(跨组), group, v, timestamp}
|
|
15
|
+
*/
|
|
16
|
+
export interface RateCommonResult {
|
|
17
|
+
counter: number;
|
|
18
|
+
v: string;
|
|
19
|
+
idle: number;
|
|
20
|
+
timestamp: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GroupDistinctCommonResult {
|
|
24
|
+
idle: number;
|
|
25
|
+
pv: number;
|
|
26
|
+
uv: number;
|
|
27
|
+
gidle: number;
|
|
28
|
+
vidle: number;
|
|
29
|
+
group: string;
|
|
30
|
+
v: string;
|
|
31
|
+
timestamp: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 频控存储端口:Redis 化实现(verdict 侧)须通过 rate-store-conformance 契约测试 */
|
|
35
|
+
export interface RateStore {
|
|
36
|
+
rate(entity: string, windowMs: number): RateCommonResult | Promise<RateCommonResult>;
|
|
37
|
+
groupDistinct(
|
|
38
|
+
group: string,
|
|
39
|
+
value: string,
|
|
40
|
+
windowMs: number,
|
|
41
|
+
): GroupDistinctCommonResult | Promise<GroupDistinctCommonResult>;
|
|
42
|
+
/** 清空状态(测试辅助;生产实现可为 no-op) */
|
|
43
|
+
reset?(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const secondsSince = (now: number, t?: number): number => (t ? Math.max(0, Math.floor((now - t) / 1000)) : 0);
|
|
47
|
+
|
|
48
|
+
/** 进程内滑动窗口参考实现(单实例语义;多副本请使用 Redis 化实现) */
|
|
49
|
+
export class InMemoryRateStore implements RateStore {
|
|
50
|
+
private rateWindows = new Map<string, number[]>();
|
|
51
|
+
private groupWindows = new Map<string, { pv: number[]; values: Map<string, number[]> }>();
|
|
52
|
+
private valueWindows = new Map<string, number[]>();
|
|
53
|
+
|
|
54
|
+
constructor(private readonly now: () => number = Date.now) {}
|
|
55
|
+
|
|
56
|
+
reset(): void {
|
|
57
|
+
this.rateWindows.clear();
|
|
58
|
+
this.groupWindows.clear();
|
|
59
|
+
this.valueWindows.clear();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
rate(entity: string, windowMs: number): RateCommonResult {
|
|
63
|
+
const now = this.now();
|
|
64
|
+
const stamps = (this.rateWindows.get(entity) ?? []).filter((t) => now - t < windowMs);
|
|
65
|
+
const previous = stamps[stamps.length - 1];
|
|
66
|
+
stamps.push(now);
|
|
67
|
+
this.rateWindows.set(entity, stamps);
|
|
68
|
+
return {
|
|
69
|
+
counter: stamps.length,
|
|
70
|
+
v: entity,
|
|
71
|
+
idle: secondsSince(now, previous),
|
|
72
|
+
timestamp: new Date(now).toISOString(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
groupDistinct(group: string, value: string, windowMs: number): GroupDistinctCommonResult {
|
|
77
|
+
const now = this.now();
|
|
78
|
+
|
|
79
|
+
const entry = this.groupWindows.get(group) ?? { pv: [], values: new Map<string, number[]>() };
|
|
80
|
+
entry.pv = entry.pv.filter((t) => now - t < windowMs);
|
|
81
|
+
const groupPrevious = entry.pv[entry.pv.length - 1];
|
|
82
|
+
entry.pv.push(now);
|
|
83
|
+
|
|
84
|
+
const valueStamps = (entry.values.get(value) ?? []).filter((t) => now - t < windowMs);
|
|
85
|
+
const pairPrevious = valueStamps[valueStamps.length - 1];
|
|
86
|
+
valueStamps.push(now);
|
|
87
|
+
entry.values.set(value, valueStamps);
|
|
88
|
+
for (const [v, stamps] of entry.values) {
|
|
89
|
+
if (stamps.filter((t) => now - t < windowMs).length === 0) entry.values.delete(v);
|
|
90
|
+
}
|
|
91
|
+
this.groupWindows.set(group, entry);
|
|
92
|
+
|
|
93
|
+
const globalStamps = (this.valueWindows.get(value) ?? []).filter((t) => now - t < windowMs);
|
|
94
|
+
const globalPrevious = globalStamps[globalStamps.length - 1];
|
|
95
|
+
globalStamps.push(now);
|
|
96
|
+
this.valueWindows.set(value, globalStamps);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
idle: secondsSince(now, pairPrevious),
|
|
100
|
+
pv: entry.pv.length,
|
|
101
|
+
uv: entry.values.size,
|
|
102
|
+
gidle: secondsSince(now, groupPrevious),
|
|
103
|
+
vidle: secondsSince(now, globalPrevious),
|
|
104
|
+
group,
|
|
105
|
+
v: value,
|
|
106
|
+
timestamp: new Date(now).toISOString(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 活动存储:缺省为进程内参考实现;宿主经 setRateStore 注入 Redis 化实现 */
|
|
112
|
+
let activeStore: RateStore = new InMemoryRateStore();
|
|
113
|
+
|
|
114
|
+
export const setRateStore = (store: RateStore): void => {
|
|
115
|
+
activeStore = store;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const getRateStore = (): RateStore => activeStore;
|
|
119
|
+
|
|
120
|
+
/** 测试辅助:重置活动存储(若实现支持 reset;生产勿用) */
|
|
121
|
+
export const __resetRateWindows = (): void => {
|
|
122
|
+
activeStore.reset?.();
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const WINDOW_MS = 60 * 60 * 1000;
|
|
126
|
+
|
|
127
|
+
const rate_1h = defineTool({
|
|
128
|
+
name: 'rate_1h',
|
|
129
|
+
description: '旧域重建·频次统计:记录实体事件并返回其 1 小时滑动窗口内的事件计数。',
|
|
130
|
+
parametersSchema: {
|
|
131
|
+
properties: {
|
|
132
|
+
entity: {
|
|
133
|
+
type: 'string',
|
|
134
|
+
title: '实体',
|
|
135
|
+
description: '计数实体(如 ip、phone)',
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
returnsSchema: {
|
|
140
|
+
type: 'object',
|
|
141
|
+
title: 'RateCommonResult',
|
|
142
|
+
properties: {
|
|
143
|
+
counter: { type: 'integer', title: 'Counter', default: 0 },
|
|
144
|
+
v: { type: 'string', title: 'V', default: '' },
|
|
145
|
+
idle: { type: 'integer', title: 'Idle', default: 0 },
|
|
146
|
+
timestamp: { type: 'string', title: 'Timestamp', default: '' },
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
fn: function rateUdf(kwargs: Record<string, unknown>) {
|
|
150
|
+
const entity = String(kwargs?.entity ?? '');
|
|
151
|
+
return Promise.resolve(getRateStore().rate(entity, WINDOW_MS));
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const group_distinct_1h = defineTool({
|
|
156
|
+
name: 'group_distinct_1h',
|
|
157
|
+
description: '旧域重建·组去重统计:记录 (组, 值) 事件并返回 1 小时滑动窗口内组事件数(pv)与去重值数(uv)。',
|
|
158
|
+
parametersSchema: {
|
|
159
|
+
properties: {
|
|
160
|
+
group: {
|
|
161
|
+
type: 'string',
|
|
162
|
+
title: '组',
|
|
163
|
+
description: '分组键(如 ip)',
|
|
164
|
+
},
|
|
165
|
+
value: {
|
|
166
|
+
type: 'string',
|
|
167
|
+
title: '值',
|
|
168
|
+
description: '组内观测值(如 phone)',
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
returnsSchema: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
title: 'GroupDistinctCommonResult',
|
|
175
|
+
properties: {
|
|
176
|
+
idle: { type: 'integer', title: 'Idle', default: 0 },
|
|
177
|
+
pv: { type: 'integer', title: 'Pv', default: 0 },
|
|
178
|
+
uv: { type: 'integer', title: 'Uv', default: 0 },
|
|
179
|
+
gidle: { type: 'integer', title: 'Gidle', default: 0 },
|
|
180
|
+
vidle: { type: 'integer', title: 'Vidle', default: 0 },
|
|
181
|
+
group: { type: 'string', title: 'Group', default: '' },
|
|
182
|
+
v: { type: 'string', title: 'V', default: '' },
|
|
183
|
+
timestamp: { type: 'string', title: 'Timestamp', default: '' },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
fn: function groupDistinctUdf(kwargs: Record<string, unknown>) {
|
|
187
|
+
const group = String(kwargs?.group ?? '');
|
|
188
|
+
const value = String(kwargs?.value ?? '');
|
|
189
|
+
return Promise.resolve(getRateStore().groupDistinct(group, value, WINDOW_MS));
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
export default defineContrib(import.meta.url, {
|
|
194
|
+
tools: [rate_1h, group_distinct_1h],
|
|
195
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { runWithExecContext } from '../exec-context.ts';
|
|
4
|
+
import { globalUdfRegistry } from '../register.ts';
|
|
5
|
+
import { registerRoster } from '../roster.ts';
|
|
6
|
+
import './custom-list-query.ts';
|
|
7
|
+
import './ip-location.ts';
|
|
8
|
+
import './rate-window.ts';
|
|
9
|
+
import { __resetRateWindows } from './rate-window.ts';
|
|
10
|
+
|
|
11
|
+
const call = <T>(name: string, args: unknown[]): Promise<T> =>
|
|
12
|
+
globalUdfRegistry.call(name, globalUdfRegistry.funcBindParams(name, args)) as Promise<T>;
|
|
13
|
+
|
|
14
|
+
describe('custom_list_query(D2 重建)', () => {
|
|
15
|
+
test('名单命中返回 result:true,未命中 false(actor 隔离)', async () => {
|
|
16
|
+
registerRoster({ name: 'clq_list', items: ['v-hit'] }, { tenantId: 'clq-tenant', actor: 'clq-user' });
|
|
17
|
+
const hit = await runWithExecContext({ tenantId: 'clq-tenant', userId: 'clq-user' }, () =>
|
|
18
|
+
call<{ result: boolean }>('custom_list_query', ['clq_list', 'v-hit']),
|
|
19
|
+
);
|
|
20
|
+
const miss = await runWithExecContext({ tenantId: 'clq-tenant', userId: 'clq-user' }, () =>
|
|
21
|
+
call<{ result: boolean }>('custom_list_query', ['clq_list', 'v-miss']),
|
|
22
|
+
);
|
|
23
|
+
expect(hit.result).toBe(true);
|
|
24
|
+
expect(miss.result).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('rate_1h / group_distinct_1h(D2 重建,内存滑动窗口)', () => {
|
|
29
|
+
test('同实体计数递增,异实体独立', async () => {
|
|
30
|
+
__resetRateWindows();
|
|
31
|
+
const a1 = await call<{ counter: number; v: string; timestamp: string }>('rate_1h', ['1.1.1.1']);
|
|
32
|
+
const a2 = await call<{ counter: number; v: string; timestamp: string }>('rate_1h', ['1.1.1.1']);
|
|
33
|
+
const b1 = await call<{ counter: number; v: string; timestamp: string }>('rate_1h', ['2.2.2.2']);
|
|
34
|
+
expect(a1.counter).toBe(1);
|
|
35
|
+
expect(a2.counter).toBe(2);
|
|
36
|
+
expect(b1.counter).toBe(1);
|
|
37
|
+
expect(typeof a1.timestamp).toBe('string');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('group_distinct_1h:pv 累计、uv 按值去重、组间独立', async () => {
|
|
41
|
+
__resetRateWindows();
|
|
42
|
+
const r1 = await call<{ pv: number; uv: number }>('group_distinct_1h', ['g-ip', 'p1']);
|
|
43
|
+
const r2 = await call<{ pv: number; uv: number }>('group_distinct_1h', ['g-ip', 'p1']);
|
|
44
|
+
const r3 = await call<{ pv: number; uv: number }>('group_distinct_1h', ['g-ip', 'p2']);
|
|
45
|
+
const other = await call<{ pv: number; uv: number }>('group_distinct_1h', ['g-other', 'p1']);
|
|
46
|
+
expect(r1.pv).toBe(1);
|
|
47
|
+
expect(r1.uv).toBe(1);
|
|
48
|
+
expect(r2.pv).toBe(2);
|
|
49
|
+
expect(r2.uv).toBe(1);
|
|
50
|
+
expect(r3.uv).toBe(2);
|
|
51
|
+
expect(other.pv).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('ip_location:数据集最长前缀命中,未配置返回空字段', async () => {
|
|
55
|
+
__resetRateWindows();
|
|
56
|
+
const miss = await call<{ country: string; ip: string }>('ip_location', ['9.9.9.9']);
|
|
57
|
+
expect(miss.country).toBe('');
|
|
58
|
+
expect(miss.ip).toBe('9.9.9.9');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { runWithExecContext } from '../exec-context.ts';
|
|
4
|
+
import { globalUdfRegistry } from '../register.ts';
|
|
5
|
+
import { deleteRoster, registerRoster } from '../roster.ts';
|
|
6
|
+
import './roster.ts';
|
|
7
|
+
|
|
8
|
+
describe('roster UDF', () => {
|
|
9
|
+
test('并发双 ctx 下各自命中 actor 私有名单', async () => {
|
|
10
|
+
registerRoster({ name: 'o_udf_a', items: ['ip-a'] }, { tenantId: 'roster-tenant', actor: 'udf-user-a' });
|
|
11
|
+
registerRoster({ name: 'o_udf_b', items: ['ip-b'] }, { tenantId: 'roster-tenant', actor: 'udf-user-b' });
|
|
12
|
+
|
|
13
|
+
const call = () =>
|
|
14
|
+
globalUdfRegistry.call('roster', globalUdfRegistry.funcBindParams('roster', ['o_udf_a', 'ip-a'])) as Promise<{
|
|
15
|
+
hit: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
const [asA, asB] = await Promise.all([
|
|
18
|
+
runWithExecContext({ tenantId: 'roster-tenant', userId: 'udf-user-a' }, call),
|
|
19
|
+
runWithExecContext({ tenantId: 'roster-tenant', userId: 'udf-user-b' }, call),
|
|
20
|
+
]);
|
|
21
|
+
expect(asA.hit).toBe(true);
|
|
22
|
+
expect(asB.hit).toBe(false);
|
|
23
|
+
|
|
24
|
+
deleteRoster('o_udf_a', { tenantId: 'roster-tenant', actor: 'udf-user-a' });
|
|
25
|
+
deleteRoster('o_udf_b', { tenantId: 'roster-tenant', actor: 'udf-user-b' });
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getExecContext } from '../exec-context.ts';
|
|
2
|
+
import { defineContrib } from '../register.ts';
|
|
3
|
+
import { queryRoster } from '../roster.ts';
|
|
4
|
+
|
|
5
|
+
export default defineContrib(import.meta.url, {
|
|
6
|
+
tools: [
|
|
7
|
+
{
|
|
8
|
+
name: 'roster',
|
|
9
|
+
description: '查询名单:在服务端指定名单中查询某个值是否存在,返回命中结果.',
|
|
10
|
+
parametersSchema: {
|
|
11
|
+
properties: {
|
|
12
|
+
roster: {
|
|
13
|
+
type: 'string',
|
|
14
|
+
title: '名单',
|
|
15
|
+
description: '服务端名单名称(从名单下拉中动态选择)',
|
|
16
|
+
},
|
|
17
|
+
value: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
title: '查询值',
|
|
20
|
+
description: '待查询的值',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ['roster', 'value'],
|
|
24
|
+
title: 'roster',
|
|
25
|
+
type: 'object',
|
|
26
|
+
},
|
|
27
|
+
returnsSchema: { type: 'object', title: 'roster 函数返回', properties: {} },
|
|
28
|
+
fn: function queryListUdf(kwargs: Record<string, unknown>) {
|
|
29
|
+
const ctx = getExecContext();
|
|
30
|
+
if (!ctx?.tenantId) return { hit: false, roster: String(kwargs?.roster ?? ''), value: kwargs?.value ?? null };
|
|
31
|
+
return queryRoster(String(kwargs?.roster ?? ''), kwargs?.value ?? null, {
|
|
32
|
+
tenantId: ctx.tenantId,
|
|
33
|
+
actor: ctx.userId,
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { DecisionCache } from './decision-cache.ts';
|
|
4
|
+
import { DecisionRuntime } from './engine.ts';
|
|
5
|
+
import { runWithExecContext } from './exec-context.ts';
|
|
6
|
+
import './reference.ts';
|
|
7
|
+
|
|
8
|
+
const makeEntry = (v: number) => ({ decision: v as unknown as never, content: v });
|
|
9
|
+
|
|
10
|
+
describe('DecisionCache(U4 L1 缓存机制)', () => {
|
|
11
|
+
test('LRU:get 刷新位次,超容量驱逐最旧', () => {
|
|
12
|
+
const cache = new DecisionCache({ capacity: 2 });
|
|
13
|
+
cache.set('a', makeEntry(1));
|
|
14
|
+
cache.set('b', makeEntry(2));
|
|
15
|
+
cache.get('a'); // a 变最新
|
|
16
|
+
cache.set('c', makeEntry(3)); // 驱逐 b
|
|
17
|
+
|
|
18
|
+
expect(cache.has('a')).toBe(true);
|
|
19
|
+
expect(cache.has('b')).toBe(false);
|
|
20
|
+
expect(cache.has('c')).toBe(true);
|
|
21
|
+
expect(cache.snapshot().evictions).toBe(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('同键 set 即原子替换(copy-on-write 热更新)', () => {
|
|
25
|
+
const cache = new DecisionCache();
|
|
26
|
+
cache.set('k', makeEntry(1));
|
|
27
|
+
cache.set('k', makeEntry(2));
|
|
28
|
+
expect(cache.get('k')?.content).toBe(2);
|
|
29
|
+
expect(cache.snapshot().evictions).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('指标:hits/misses 计数与快照', () => {
|
|
33
|
+
const cache = new DecisionCache();
|
|
34
|
+
cache.set('k', makeEntry(1));
|
|
35
|
+
cache.get('k');
|
|
36
|
+
cache.get('missing');
|
|
37
|
+
const snap = cache.snapshot();
|
|
38
|
+
expect(snap.hits).toBe(1);
|
|
39
|
+
expect(snap.misses).toBe(1);
|
|
40
|
+
expect(snap.size).toBe(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('metricsSink 在读写后回调', () => {
|
|
44
|
+
const snapshots: number[] = [];
|
|
45
|
+
const cache = new DecisionCache({ metricsSink: (s) => snapshots.push(s.size) });
|
|
46
|
+
cache.set('k', makeEntry(1));
|
|
47
|
+
cache.get('k');
|
|
48
|
+
expect(snapshots.length).toBeGreaterThanOrEqual(2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('build 计时埋点累计 builds/buildMicros', () => {
|
|
52
|
+
const cache = new DecisionCache();
|
|
53
|
+
const t = cache.markBuildStart();
|
|
54
|
+
cache.markBuildEnd(t);
|
|
55
|
+
const snap = cache.snapshot();
|
|
56
|
+
expect(snap.builds).toBe(1);
|
|
57
|
+
expect(snap.buildMicros).toBeGreaterThanOrEqual(0);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('DecisionRuntime L1 缓存接入(U4)', () => {
|
|
62
|
+
const graph = {
|
|
63
|
+
id: 'g',
|
|
64
|
+
nodes: [
|
|
65
|
+
{ id: 'in', type: 'inputNode', name: 'Request' },
|
|
66
|
+
{ id: 'out', type: 'outputNode', name: 'Response' },
|
|
67
|
+
],
|
|
68
|
+
edges: [],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
test('缓存键按租户隔离:同 key 不同租户互不可见', async () => {
|
|
72
|
+
const runtime = new DecisionRuntime({});
|
|
73
|
+
await runWithExecContext({ tenantId: 't-1' }, async () => {
|
|
74
|
+
runtime.createDecisionWithCacheKey('model', structuredClone(graph));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// t-2 命中不了 t-1 的条目
|
|
78
|
+
await expect(runWithExecContext({ tenantId: 't-2' }, () => runtime.evaluateAsync('model', {}))).rejects.toThrow(
|
|
79
|
+
/not found, please use createDecisionWithCacheKey/,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// t-1 正常命中
|
|
83
|
+
const result = await runWithExecContext({ tenantId: 't-1' }, () => runtime.evaluateAsync('model', {}));
|
|
84
|
+
expect(result.result).toBeDefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('同租户同 key 不同 rev 并存', async () => {
|
|
88
|
+
const runtime = new DecisionRuntime({});
|
|
89
|
+
await runWithExecContext({ tenantId: 't-1' }, () => {
|
|
90
|
+
runtime.createDecisionWithCacheKey('model', structuredClone(graph), 'v1');
|
|
91
|
+
runtime.createDecisionWithCacheKey('model', structuredClone(graph), 'v2');
|
|
92
|
+
return Promise.resolve();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await runWithExecContext({ tenantId: 't-1' }, async () => {
|
|
96
|
+
expect(runtime.getDecisionCache('model', 'v1')).toBeDefined();
|
|
97
|
+
expect(runtime.getDecisionCache('model', 'v2')).toBeDefined();
|
|
98
|
+
expect(runtime.getContentCache('model')).toBeUndefined(); // 缺省 rev=latest → 未创建
|
|
99
|
+
expect(runtime.getContentCache('model', 'v1')).toBeDefined();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('update/delete 按 rev 定位,latest 互不干扰', async () => {
|
|
104
|
+
const runtime = new DecisionRuntime({});
|
|
105
|
+
await runWithExecContext({ tenantId: 't-1' }, () => {
|
|
106
|
+
runtime.createDecisionWithCacheKey('m', structuredClone(graph), 'v1');
|
|
107
|
+
runtime.updateDecisionWithCacheKey('m', structuredClone(graph), 'v1');
|
|
108
|
+
runtime.deleteDecisionWithCacheKey('m', 'v1');
|
|
109
|
+
return Promise.resolve();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await runWithExecContext({ tenantId: 't-1' }, () => {
|
|
113
|
+
expect(runtime.getDecisionCache('m', 'v1')).toBeUndefined();
|
|
114
|
+
return Promise.resolve();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('缺省构造回落 globalUdfRegistry(reference 装载后工具非空)', () => {
|
|
119
|
+
const runtime = new DecisionRuntime();
|
|
120
|
+
expect(runtime.udfFunctionSchemaTools().length).toBeGreaterThan(0);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ZenDecision } from '@gorules/zen-engine';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* L1 决策缓存(多租户设计 docs/design/zen-udf-multi-tenant.md §3)。
|
|
5
|
+
*
|
|
6
|
+
* 探针实证(src/engine-cache-semantics.test.ts):zen-engine 函数 loader 无引擎级缓存,
|
|
7
|
+
* 缓存责任在宿主——本类即宿主侧缓存机制:
|
|
8
|
+
* - 键由 DecisionRuntime 组合(`${tenantId}:${key}@${rev}`),本类只管存取与驱逐
|
|
9
|
+
* - Map 插入序实现 LRU:get 触碰刷新位次,超容量按最旧驱逐
|
|
10
|
+
* - 原子替换语义:set 同键即整体换新(copy-on-write 热更新),绝不原地改
|
|
11
|
+
* - in-flight 安全:被驱逐条目若仍被请求持有引用,evaluate 不受影响,GC 兜底
|
|
12
|
+
*/
|
|
13
|
+
export interface CacheMetricsSnapshot {
|
|
14
|
+
hits: number;
|
|
15
|
+
misses: number;
|
|
16
|
+
evictions: number;
|
|
17
|
+
builds: number;
|
|
18
|
+
buildMicros: number;
|
|
19
|
+
size: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DecisionCacheEntry {
|
|
23
|
+
decision: ZenDecision;
|
|
24
|
+
content: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DecisionCacheOptions {
|
|
28
|
+
/** 容量上限(条目数),默认 500 */
|
|
29
|
+
capacity?: number;
|
|
30
|
+
/** 指标 sink:每次 get/set/delete 后回调快照(verdict 接 Prometheus 用) */
|
|
31
|
+
metricsSink?: (snapshot: CacheMetricsSnapshot) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_CAPACITY = 500;
|
|
35
|
+
|
|
36
|
+
export class DecisionCache {
|
|
37
|
+
private readonly entries = new Map<string, DecisionCacheEntry>();
|
|
38
|
+
private readonly capacity: number;
|
|
39
|
+
private readonly metricsSink?: (snapshot: CacheMetricsSnapshot) => void;
|
|
40
|
+
private metrics = { hits: 0, misses: 0, evictions: 0, builds: 0, buildMicros: 0 };
|
|
41
|
+
|
|
42
|
+
constructor(options: DecisionCacheOptions = {}) {
|
|
43
|
+
this.capacity = Math.max(1, options.capacity ?? DEFAULT_CAPACITY);
|
|
44
|
+
this.metricsSink = options.metricsSink;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get size(): number {
|
|
48
|
+
return this.entries.size;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 命中并刷新 LRU 位次;未命中计 miss */
|
|
52
|
+
get(key: string): DecisionCacheEntry | undefined {
|
|
53
|
+
const entry = this.entries.get(key);
|
|
54
|
+
if (entry) {
|
|
55
|
+
this.metrics.hits += 1;
|
|
56
|
+
// 刷新位次(Map 迭代序 = 插入序,删除再插入即移到最新)
|
|
57
|
+
this.entries.delete(key);
|
|
58
|
+
this.entries.set(key, entry);
|
|
59
|
+
} else {
|
|
60
|
+
this.metrics.misses += 1;
|
|
61
|
+
}
|
|
62
|
+
this.metricsSink?.(this.snapshot());
|
|
63
|
+
return entry;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 写入/原子替换;超容量按最旧驱逐 */
|
|
67
|
+
set(key: string, entry: DecisionCacheEntry): void {
|
|
68
|
+
if (this.entries.has(key)) {
|
|
69
|
+
this.entries.delete(key);
|
|
70
|
+
} else if (this.entries.size >= this.capacity) {
|
|
71
|
+
const oldest = this.entries.keys().next().value;
|
|
72
|
+
if (oldest !== undefined) {
|
|
73
|
+
this.entries.delete(oldest);
|
|
74
|
+
this.metrics.evictions += 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
this.entries.set(key, entry);
|
|
78
|
+
this.metricsSink?.(this.snapshot());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
delete(key: string): boolean {
|
|
82
|
+
const deleted = this.entries.delete(key);
|
|
83
|
+
this.metricsSink?.(this.snapshot());
|
|
84
|
+
return deleted;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
has(key: string): boolean {
|
|
88
|
+
return this.entries.has(key);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 构建计时埋点:DecisionRuntime 在 createDecision 前后调用 */
|
|
92
|
+
markBuildStart(): bigint {
|
|
93
|
+
return process.hrtime.bigint();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
markBuildEnd(startedAt: bigint): void {
|
|
97
|
+
this.metrics.builds += 1;
|
|
98
|
+
this.metrics.buildMicros += Number(process.hrtime.bigint() - startedAt) / 1000;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
snapshot(): CacheMetricsSnapshot {
|
|
102
|
+
return { ...this.metrics, size: this.entries.size };
|
|
103
|
+
}
|
|
104
|
+
}
|