e07b12daf9
调整事件查询排序及分页游标,保留入库时间快照隔离。 同步后台说明、定向测试和技术文档。
411 lines
12 KiB
JavaScript
411 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
// 仅在临时 standalone 验证客户端埋点;显式传入带测试 bootstrap hash 的 WASM。
|
|
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { once } from 'node:events';
|
|
import { access, chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import net from 'node:net';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { createSpacetimeWebIdentity } from './spacetime-migration-common.mjs';
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const database = 'agc-analytics-smoke';
|
|
const testSecret = 'a'.repeat(64); // 公开测试值,绝不能用于正式部署。
|
|
const sensitive = [testSecret];
|
|
const redact = (value) =>
|
|
sensitive.reduce(
|
|
(text, secret) => text.replaceAll(secret, '[REDACTED]'),
|
|
String(value),
|
|
);
|
|
|
|
function command(args) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn('spacetime', args, {
|
|
cwd: root,
|
|
windowsHide: true,
|
|
shell: false,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let output = '';
|
|
for (const stream of [child.stdout, child.stderr])
|
|
stream.on('data', (chunk) => {
|
|
output = (output + chunk).slice(-16000);
|
|
});
|
|
const timer = setTimeout(() => {
|
|
child.kill();
|
|
reject(new Error('SpacetimeDB command timed out'));
|
|
}, 120000);
|
|
child.once('error', (error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
});
|
|
child.once('exit', (code) => {
|
|
clearTimeout(timer);
|
|
if (code === 0) resolve(output);
|
|
else reject(new Error(redact(output)));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function localPort() {
|
|
const listener = net.createServer();
|
|
listener.listen(0, '127.0.0.1');
|
|
await once(listener, 'listening');
|
|
const port = listener.address().port;
|
|
await new Promise((resolve) => listener.close(resolve));
|
|
return port;
|
|
}
|
|
|
|
async function call(url, token, name, input) {
|
|
const response = await fetch(`${url}/v1/database/${database}/call/${name}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify([input]),
|
|
signal: AbortSignal.timeout(30000),
|
|
});
|
|
const text = await response.text();
|
|
assert(response.ok, `${name}: HTTP ${response.status}: ${redact(text)}`);
|
|
return JSON.parse(text);
|
|
}
|
|
|
|
function ok(result) {
|
|
assert.equal(result[0], 0, `Expected Ok, got ${JSON.stringify(result)}`);
|
|
return JSON.parse(result[1]);
|
|
}
|
|
|
|
const event = (user = 'smoke-user-a') => ({
|
|
schema_version: 1,
|
|
event_id: randomUUID(),
|
|
event_name: 'editor_session_start',
|
|
event_time: '2026-09-21T12:00:00.123Z',
|
|
user_id: user,
|
|
editor_session_id: randomUUID(),
|
|
project_id: null,
|
|
creative_task_id: null,
|
|
agent_run_id: null,
|
|
agent_turn_id: null,
|
|
status: 'success',
|
|
error_code: null,
|
|
source: 'editor',
|
|
client_version: 'smoke-1',
|
|
properties: { entry_source: 'direct_launch', first_project_id: null },
|
|
});
|
|
const batch = (events) => ({
|
|
schema_version: 1,
|
|
batch_id: randomUUID(),
|
|
destination_origin: 'https://analytics-smoke.invalid',
|
|
user_id: events[0].user_id,
|
|
events,
|
|
});
|
|
|
|
function pricingInput() {
|
|
const tiered = (model, unit, keys) => ({
|
|
model,
|
|
unit,
|
|
price: [1, []],
|
|
prices: keys.map((key) => ({ key, price: 1 })),
|
|
});
|
|
return {
|
|
admin_user_id: 'smoke-bootstrap',
|
|
updated_at_micros: 1,
|
|
bootstrap_secret: testSecret,
|
|
models: [
|
|
tiered('gemini-3.1-flash-image-preview', 'perGeneration', [
|
|
'0.5K',
|
|
'1K',
|
|
'2K',
|
|
]),
|
|
tiered('gpt-image-2', 'perGeneration', ['1K', '2K']),
|
|
...['audio1.0', 'eleven_text_to_sound_v2', 'chirp-v5'].map((model) => ({
|
|
model,
|
|
unit: 'perGeneration',
|
|
price: [0, 1],
|
|
prices: [],
|
|
})),
|
|
...[
|
|
'seedance2.0-fast',
|
|
'seedance2.0',
|
|
'kling3.0',
|
|
'kling3.0-omni',
|
|
'veo3.1',
|
|
'veo3.1-fast',
|
|
].map((model) => tiered(model, 'perSecond', ['480p', '720p', '1080p'])),
|
|
],
|
|
};
|
|
}
|
|
|
|
async function verify(url, serviceToken, outsiderToken) {
|
|
const upload = async (payload) =>
|
|
call(
|
|
url,
|
|
serviceToken,
|
|
'upload_agc_analytics_batch',
|
|
JSON.stringify(payload),
|
|
);
|
|
const list = async (query = {}) =>
|
|
ok(
|
|
await call(
|
|
url,
|
|
serviceToken,
|
|
'list_agc_tracking_events',
|
|
JSON.stringify(query),
|
|
),
|
|
);
|
|
const first = batch([event(), event(), event()]);
|
|
first.events[0].event_time = '2026-09-21T12:01:00.123Z';
|
|
assert.deepEqual(ok(await upload(first)), {
|
|
acknowledged_batch_ids: [first.batch_id],
|
|
event_count: 3,
|
|
});
|
|
const initial = await list();
|
|
assert.equal(initial.entries.length, 3);
|
|
ok(await upload(first));
|
|
assert.deepEqual(
|
|
await list(),
|
|
initial,
|
|
'Replay must preserve rows, first batch ID and receipt time',
|
|
);
|
|
const reordered = structuredClone(first);
|
|
reordered.events[0].properties = {
|
|
first_project_id: null,
|
|
entry_source: 'direct_launch',
|
|
};
|
|
ok(await upload(reordered));
|
|
assert.deepEqual(
|
|
await list(),
|
|
initial,
|
|
'JSON property order must not cause conflict',
|
|
);
|
|
|
|
const rollbackCandidate = event();
|
|
const conflict = batch([
|
|
rollbackCandidate,
|
|
{ ...first.events[0], client_version: 'changed' },
|
|
]);
|
|
const rejected = await upload(conflict);
|
|
assert.equal(rejected[0], 1);
|
|
assert.equal(rejected[1], 'agc_event_conflict');
|
|
assert.deepEqual(
|
|
await list(),
|
|
initial,
|
|
'Earlier insert in failed batch must roll back',
|
|
);
|
|
const userB = batch([event('smoke-user-b')]);
|
|
ok(await upload(userB));
|
|
assert.equal(
|
|
(await list({ userId: 'smoke-user-b' })).entries[0].eventId,
|
|
userB.events[0].event_id,
|
|
);
|
|
|
|
const page1 = await list({ limit: 2 });
|
|
assert(page1.nextCursor);
|
|
const late = batch([event()]);
|
|
late.events[0].event_time = '2026-09-21T11:00:00.123Z';
|
|
ok(await upload(late));
|
|
const seen = [...page1.entries];
|
|
let cursor = page1.nextCursor;
|
|
while (cursor) {
|
|
const page = await list({ limit: 2, cursor });
|
|
seen.push(...page.entries);
|
|
cursor = page.nextCursor;
|
|
}
|
|
assert.equal(seen.length, 4);
|
|
assert.equal(new Set(seen.map((row) => row.eventId)).size, 4);
|
|
assert(
|
|
!seen.some((row) => row.eventId === late.events[0].event_id),
|
|
'Cursor snapshot must exclude later insert',
|
|
);
|
|
for (let index = 1; index < seen.length; index++) {
|
|
const previous = seen[index - 1];
|
|
const current = seen[index];
|
|
assert(
|
|
previous.eventTime > current.eventTime ||
|
|
(previous.eventTime === current.eventTime &&
|
|
previous.eventId > current.eventId),
|
|
'Stable event time/event ID descending order',
|
|
);
|
|
}
|
|
const refreshed = await list();
|
|
assert.equal(refreshed.entries.length, 5);
|
|
assert.equal(refreshed.entries[0].eventId, first.events[0].event_id);
|
|
assert.equal(refreshed.entries.at(-1).eventId, late.events[0].event_id);
|
|
assert.equal(refreshed.entries[0].projectId, null);
|
|
assert.equal(refreshed.entries[0].eventTime, '2026-09-21T12:01:00.123Z');
|
|
for (const [name, input] of [
|
|
['upload_agc_analytics_batch', first],
|
|
['list_agc_tracking_events', {}],
|
|
]) {
|
|
const unauthorized = await call(
|
|
url,
|
|
outsiderToken,
|
|
name,
|
|
JSON.stringify(input),
|
|
);
|
|
assert.equal(unauthorized[0], 1, 'Nonservice identity must be rejected');
|
|
assert.match(unauthorized[1], /无权/);
|
|
}
|
|
console.log(
|
|
'[agc-analytics-smoke] PASS: first/replay, JSON order, atomic rollback, user filter, stable snapshot pagination, refresh, null/time, service authorization. 5 rows persisted.',
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const wasm = process.env.GENARRATIVE_AGC_ANALYTICS_SMOKE_WASM;
|
|
assert(
|
|
wasm,
|
|
'Set GENARRATIVE_AGC_ANALYTICS_SMOKE_WASM to an isolated test WASM compiled with SHA256(a repeated 64 times) bootstrap hash.',
|
|
);
|
|
await access(wasm);
|
|
const version = await command(['--version']);
|
|
assert(
|
|
version.includes('version 2.8.3') &&
|
|
version.includes('8e410d2842147bd8e5a32a9589cc00c19f7478e2'),
|
|
);
|
|
const temp = await mkdtemp(
|
|
path.join(os.tmpdir(), 'genarrative-agc-analytics-smoke-'),
|
|
);
|
|
let standalone;
|
|
try {
|
|
const port = await localPort();
|
|
const url = `http://127.0.0.1:${port}`;
|
|
standalone = spawn(
|
|
'spacetime',
|
|
[
|
|
'start',
|
|
'--data-dir',
|
|
path.join(temp, 'data'),
|
|
'--listen-addr',
|
|
`127.0.0.1:${port}`,
|
|
'--non-interactive',
|
|
],
|
|
{ windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
);
|
|
let logs = '';
|
|
for (const stream of [standalone.stdout, standalone.stderr])
|
|
stream.on('data', (chunk) => {
|
|
logs = (logs + chunk).slice(-8000);
|
|
});
|
|
const deadline = Date.now() + 30000;
|
|
for (;;) {
|
|
assert(standalone.exitCode === null, redact(logs));
|
|
try {
|
|
if (
|
|
(await fetch(`${url}/v1/ping`, { signal: AbortSignal.timeout(1000) }))
|
|
.ok
|
|
)
|
|
break;
|
|
} catch {
|
|
/* 等待启动 */
|
|
}
|
|
assert(
|
|
Date.now() < deadline,
|
|
`Standalone startup timeout: ${redact(logs)}`,
|
|
);
|
|
await delay(200);
|
|
}
|
|
const owner = await createSpacetimeWebIdentity({
|
|
database,
|
|
serverUrl: url,
|
|
});
|
|
const service = await createSpacetimeWebIdentity({
|
|
database,
|
|
serverUrl: url,
|
|
});
|
|
sensitive.push(owner.token, service.token);
|
|
const config = path.join(temp, 'cli.toml');
|
|
await command(['--config-path', config, 'login', '--token', owner.token]);
|
|
await chmod(config, 0o600);
|
|
await command([
|
|
'--config-path',
|
|
config,
|
|
'publish',
|
|
database,
|
|
'--server',
|
|
url,
|
|
'--yes=all',
|
|
'--no-config',
|
|
'--bin-path',
|
|
path.resolve(wasm),
|
|
]);
|
|
const bootstrap = await call(
|
|
url,
|
|
service.token,
|
|
'initialize_editor_generation_pricing_config_if_missing_and_return',
|
|
pricingInput(),
|
|
);
|
|
assert.equal(
|
|
bootstrap[0],
|
|
true,
|
|
`Pricing service bootstrap failed: ${redact(JSON.stringify(bootstrap))}`,
|
|
);
|
|
await verify(url, service.token, owner.token);
|
|
if (process.env.AGC_ANALYTICS_SMOKE_KEEP === '1') {
|
|
const stopFile = path.join(temp, 'stop');
|
|
const contextPath = path.join(temp, 'connection.json');
|
|
await writeFile(
|
|
contextPath,
|
|
JSON.stringify({
|
|
serverUrl: url,
|
|
database,
|
|
token: service.token,
|
|
operatorToken: owner.token,
|
|
configPath: config,
|
|
stopFile,
|
|
}),
|
|
{ mode: 0o600 },
|
|
);
|
|
console.log(
|
|
`[agc-analytics-smoke] Integration connection file: ${contextPath}`,
|
|
);
|
|
const stopDeadline = Date.now() + 45 * 60 * 1000;
|
|
while (Date.now() < stopDeadline) {
|
|
try {
|
|
await access(stopFile);
|
|
break;
|
|
} catch {
|
|
/* 联调结束后由调用方创建 stop 文件 */
|
|
}
|
|
await delay(1000);
|
|
}
|
|
}
|
|
} finally {
|
|
if (standalone && standalone.exitCode === null) {
|
|
if (process.platform === 'win32') {
|
|
const killer = spawn(
|
|
'taskkill',
|
|
['/PID', String(standalone.pid), '/T', '/F'],
|
|
{ windowsHide: true, stdio: 'ignore' },
|
|
);
|
|
await once(killer, 'exit');
|
|
} else {
|
|
standalone.kill('SIGTERM');
|
|
}
|
|
if (standalone.exitCode === null) await once(standalone, 'exit');
|
|
}
|
|
// 仅删除本脚本 mkdtemp 创建的系统临时子目录。
|
|
assert(
|
|
path.dirname(path.resolve(temp)) === path.resolve(os.tmpdir()) &&
|
|
path.basename(temp).startsWith('genarrative-agc-analytics-smoke-'),
|
|
);
|
|
await rm(temp, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 5,
|
|
retryDelay: 200,
|
|
});
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(redact(error.stack ?? error));
|
|
process.exitCode = 1;
|
|
});
|