Зачем: владелец пометил N13 как «не воспроизведено», а в коде окно гонки (SELECT ... FOR UPDATE по отсутствующей строке, затем INSERT ... ON CONFLICT DO NOTHING со своим runId) видно. Нужен повторяемый замер на живом сервере и реальной PostgreSQL 17, а не рассуждение. Что: scripts/test/n13-race.mjs — 10/25/50 параллельных первых POST /payroll/calculate, каждая попытка на новой точке, проверка статусов, числа run_id, строк в staff_payroll_runs и «фантомных» run_id. Режим as-is показывает, что сейчас всё упирается в N2 (500). Режим n2-bypass ставит DEFAULT now() только в одноразовой БД скрипта (продукт и миграции не меняются), чтобы увидеть саму гонку. Итог: 0 некорректных из 30 попыток; причина (из кода) — случайная сериализация через upsert ensureDefaultPayRules. Это записано в NOTES-FOR-EXECUTORS вместе с рекомендацией явной блокировки и замечанием об утечке текста ошибок БД в 500.
298 lines
11 KiB
JavaScript
298 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
// N13: гонка первого расчёта зарплаты. N параллельных POST /payroll/calculate для одной и той же
|
|
// пары (точка, месяц), когда расчёта ещё нет. Каждая попытка — новая точка (значит «первый» расчёт).
|
|
//
|
|
// Режимы:
|
|
// --mode as-is код и схема как есть (N2 не обходится);
|
|
// --mode n2-bypass ДИАГНОСТИКА: в одноразовой БД мигратор ставит DEFAULT now() для
|
|
// staff_outbox.occurred_at, чтобы обойти N2 и увидеть саму гонку.
|
|
// Продукт/миграции не меняются, БД удаляется при следующем provision.
|
|
// Опции: --sizes 10,25,50 --attempts 5 --with-sessions (подтверждённые смены, нужен n2-bypass)
|
|
// Требует E2E_PG_ADMIN_URL (bootstrap-админ только для создания ролей/БД).
|
|
// Правильное поведение: все ответы 200, один и тот же run_id, ровно один расчёт в БД,
|
|
// каждый возвращённый run_id существует в БД.
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { createWriteStream, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
import { createServer } from 'node:net';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import pg from 'pg';
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, '../..');
|
|
const args = process.argv.slice(2);
|
|
const opt = (name, fallback) => {
|
|
const index = args.indexOf(`--${name}`);
|
|
return index >= 0 ? args[index + 1] : fallback;
|
|
};
|
|
const mode = opt('mode', 'as-is');
|
|
const sizes = opt('sizes', '10,25,50').split(',').map(Number);
|
|
const attempts = Number(opt('attempts', '5'));
|
|
const withSessions = args.includes('--with-sessions');
|
|
const database = opt('db', `krona_staff_n13_${mode.replace(/-/g, '_')}`);
|
|
|
|
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
const ulid = () =>
|
|
Array.from(randomBytes(26), (byte) => CROCKFORD[byte % 32])
|
|
.join('')
|
|
.replace(/^./, '0');
|
|
|
|
function parseEnvFile(file) {
|
|
const result = {};
|
|
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
const index = line.indexOf('=');
|
|
if (index > 0) result[line.slice(0, index)] = line.slice(index + 1);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function runNode(scriptArgs, env) {
|
|
const result = spawnSync(process.execPath, scriptArgs, { cwd: repoRoot, env, encoding: 'utf8' });
|
|
if (result.status !== 0) {
|
|
process.stdout.write(`${result.stdout}${result.stderr}`);
|
|
throw new Error(`${scriptArgs.join(' ')} failed`);
|
|
}
|
|
return result.stdout;
|
|
}
|
|
|
|
async function freePort() {
|
|
return new Promise((resolve) => {
|
|
const server = createServer();
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const { port } = server.address();
|
|
server.close(() => resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
const adminUrl = process.env.E2E_PG_ADMIN_URL;
|
|
if (!adminUrl) throw new Error('E2E_PG_ADMIN_URL не задан');
|
|
const secretsDir = mkdtempSync(path.join(tmpdir(), 'krona-n13-'));
|
|
runNode(
|
|
['scripts/test/provision-pg.mjs', '--out', path.join(secretsDir, 'db.env'), '--db', database],
|
|
{
|
|
...process.env,
|
|
},
|
|
);
|
|
const dbEnv = parseEnvFile(path.join(secretsDir, 'db.env'));
|
|
rmSync(secretsDir, { recursive: true, force: true });
|
|
runNode(['scripts/test/migrate-roundtrip.mjs', 'up'], {
|
|
PATH: process.env.PATH ?? '',
|
|
DATABASE_MIGRATION_URL: dbEnv.DATABASE_MIGRATION_URL,
|
|
});
|
|
const migrator = new pg.Client({ connectionString: dbEnv.DATABASE_MIGRATION_URL });
|
|
await migrator.connect();
|
|
if (mode === 'n2-bypass') {
|
|
await migrator.query('ALTER TABLE staff_outbox ALTER COLUMN occurred_at SET DEFAULT now()');
|
|
console.log(
|
|
'[n13] ДИАГНОСТИКА: staff_outbox.occurred_at DEFAULT now() (обход N2 только в этой БД)',
|
|
);
|
|
}
|
|
|
|
const tenantId = ulid();
|
|
const managerToken = randomBytes(32).toString('hex');
|
|
const terminalToken = randomBytes(32).toString('hex');
|
|
const vapid = {};
|
|
for (const line of runNode(['scripts/generate-vapid-keys.mjs'], process.env).split('\n')) {
|
|
const index = line.indexOf('=');
|
|
if (index > 0) vapid[line.slice(0, index)] = line.slice(index + 1);
|
|
}
|
|
const port = await freePort();
|
|
const log = createWriteStream(path.join(repoRoot, `tests/e2e/evidence/server-n13-${mode}.txt`));
|
|
const child = spawn(path.join(repoRoot, 'node_modules/.bin/tsx'), ['src/server.ts'], {
|
|
cwd: repoRoot,
|
|
env: {
|
|
PATH: process.env.PATH ?? '',
|
|
NODE_ENV: 'test',
|
|
HOST: '127.0.0.1',
|
|
PORT: String(port),
|
|
LOG_LEVEL: 'warn',
|
|
STAFF_RUNTIME_MODE: 'standalone',
|
|
DATABASE_URL: dbEnv.DATABASE_URL,
|
|
STANDALONE_TENANT_ID: tenantId,
|
|
STANDALONE_ACTOR_ID: ulid(),
|
|
STANDALONE_ACCESS: 'employee',
|
|
STANDALONE_MANAGER_TOKEN: managerToken,
|
|
STANDALONE_TERMINAL_TOKEN: terminalToken,
|
|
STANDALONE_TERMINAL_ACTOR_ID: ulid(),
|
|
...vapid,
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
child.stdout.pipe(log);
|
|
child.stderr.pipe(log);
|
|
const base = `http://127.0.0.1:${port}/api/v1/staff`;
|
|
|
|
async function api(method, route, token, body) {
|
|
const response = await fetch(`${base}${route}`, {
|
|
method,
|
|
headers: {
|
|
authorization: `Bearer ${token}`,
|
|
...(body ? { 'content-type': 'application/json' } : {}),
|
|
},
|
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
});
|
|
const text = await response.text();
|
|
let json = null;
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch {
|
|
// не JSON
|
|
}
|
|
return { status: response.status, json, text };
|
|
}
|
|
|
|
try {
|
|
for (let i = 0; i < 240; i += 1) {
|
|
try {
|
|
if ((await fetch(`${base}/ready`)).status === 200) break;
|
|
} catch {
|
|
// ещё не слушает
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
await migrator.query("SELECT set_config('app.tenant_id', $1, false)", [tenantId]);
|
|
|
|
const month = (() => {
|
|
const date = new Date();
|
|
date.setUTCDate(1);
|
|
date.setUTCMonth(date.getUTCMonth() - 1);
|
|
return date.toISOString().slice(0, 7);
|
|
})();
|
|
let hourlyRule = null;
|
|
if (withSessions) {
|
|
const rules = await api('GET', '/pay-rules', managerToken);
|
|
hourlyRule = (
|
|
rules.json.pay_rules.find((rule) => rule.code === 'hourly') ?? rules.json.pay_rules[0]
|
|
).id;
|
|
}
|
|
|
|
async function prepareUnit() {
|
|
const code = `n13-${ulid().slice(-10).toLowerCase()}`;
|
|
const suffix = ulid().slice(-8);
|
|
const imported = await api('POST', '/imports/owner-workbook', managerToken, {
|
|
source_name: `n13-import-${suffix}`,
|
|
units: [{ code, name: `Синтетическая точка ${suffix}` }],
|
|
...(withSessions
|
|
? {
|
|
employees: [
|
|
{
|
|
external_ref: `n13:${suffix}`,
|
|
personnel_number: `N13-${suffix}`,
|
|
display_name: `Синтетический ${suffix}`,
|
|
},
|
|
],
|
|
}
|
|
: {}),
|
|
});
|
|
if (imported.status >= 300) throw new Error(`import ${imported.status} ${imported.text}`);
|
|
const unitId = (await migrator.query('SELECT id FROM staff_org_units WHERE code = $1', [code]))
|
|
.rows[0].id;
|
|
if (withSessions) {
|
|
const employeeId = (
|
|
await migrator.query('SELECT id FROM staff_employees WHERE external_ref = $1', [
|
|
`n13:${suffix}`,
|
|
])
|
|
).rows[0].id;
|
|
const assign = await api('PUT', `/employees/${employeeId}/pay-assignment`, managerToken, {
|
|
pay_rule_id: hourlyRule,
|
|
org_unit_id: unitId,
|
|
rate_minor: 12345,
|
|
effective_from: '2026-01-01',
|
|
});
|
|
if (assign.status !== 200) throw new Error(`assign ${assign.status} ${assign.text}`);
|
|
const start = new Date(`${month}-10T09:00:00Z`);
|
|
for (const [eventType, at] of [
|
|
['check_in', start],
|
|
['check_out', new Date(start.getTime() + 8 * 3_600_000)],
|
|
]) {
|
|
const event = await api('POST', '/attendance/events', terminalToken, {
|
|
employee_id: employeeId,
|
|
event_type: eventType,
|
|
occurred_at: at.toISOString(),
|
|
source_event_id: `n13-${ulid()}`,
|
|
org_unit_id: unitId,
|
|
});
|
|
if (event.status !== 201) throw new Error(`clock ${event.status} ${event.text}`);
|
|
}
|
|
const day = start.toISOString().slice(0, 10);
|
|
const reconcile = await api('POST', '/work-sessions/reconcile', managerToken, {
|
|
date_from: day,
|
|
date_to: day,
|
|
});
|
|
if (reconcile.status !== 200) throw new Error(`reconcile ${reconcile.status}`);
|
|
const sessionId = (
|
|
await migrator.query('SELECT id FROM staff_work_sessions WHERE employee_id = $1', [
|
|
employeeId,
|
|
])
|
|
).rows[0].id;
|
|
const confirm = await api('PATCH', `/work-sessions/${sessionId}`, managerToken, {
|
|
status: 'confirmed',
|
|
});
|
|
if (confirm.status !== 200)
|
|
throw new Error(`confirm ${confirm.status} ${confirm.text.slice(0, 200)}`);
|
|
}
|
|
return unitId;
|
|
}
|
|
|
|
let totalBad = 0;
|
|
const summary = [];
|
|
for (const size of sizes) {
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
const unitId = await prepareUnit();
|
|
const responses = await Promise.all(
|
|
Array.from({ length: size }, () =>
|
|
api('POST', '/payroll/calculate', managerToken, { month, org_unit_id: unitId }),
|
|
),
|
|
);
|
|
const statuses = {};
|
|
const errors = {};
|
|
const runIds = new Set();
|
|
for (const response of responses) {
|
|
statuses[response.status] = (statuses[response.status] ?? 0) + 1;
|
|
if (response.status === 200) {
|
|
const id = response.json?.run_id ?? response.json?.id ?? response.json?.run?.id;
|
|
if (id) runIds.add(id);
|
|
} else {
|
|
const key = (
|
|
response.json?.error?.message ??
|
|
response.json?.message ??
|
|
response.text
|
|
).slice(0, 90);
|
|
errors[key] = (errors[key] ?? 0) + 1;
|
|
}
|
|
}
|
|
const dbRuns = (
|
|
await migrator.query('SELECT id FROM staff_payroll_runs WHERE org_unit_id = $1', [unitId])
|
|
).rows.map((row) => row.id);
|
|
const dbLines = Number(
|
|
(
|
|
await migrator.query(
|
|
'SELECT count(*) AS n FROM staff_payroll_lines WHERE org_unit_id = $1',
|
|
[unitId],
|
|
)
|
|
).rows[0].n,
|
|
);
|
|
const phantom = [...runIds].filter((id) => !dbRuns.includes(id));
|
|
const ok =
|
|
(statuses[200] ?? 0) === size &&
|
|
runIds.size === 1 &&
|
|
dbRuns.length === 1 &&
|
|
phantom.length === 0;
|
|
if (!ok) totalBad += 1;
|
|
const line =
|
|
`[n13] mode=${mode} sessions=${withSessions} N=${size} attempt=${attempt}: ` +
|
|
`statuses=${JSON.stringify(statuses)} distinct_run_ids=${runIds.size} db_runs=${dbRuns.length} db_lines=${dbLines} ` +
|
|
`phantom_run_ids=${phantom.length} errors=${JSON.stringify(errors)} -> ${ok ? 'OK' : 'BAD'}`;
|
|
console.log(line);
|
|
summary.push(ok);
|
|
}
|
|
}
|
|
console.log(
|
|
`[n13] ИТОГ mode=${mode} sessions=${withSessions}: попыток ${summary.length}, некорректных ${totalBad}`,
|
|
);
|
|
process.exitCode = totalBad > 0 ? 1 : 0;
|
|
} finally {
|
|
await migrator.end();
|
|
child.kill('SIGTERM');
|
|
}
|