fix(alerts): подтверждать конфигурацию внутри транзакции сохранения

This commit is contained in:
Denozordec committed 2026-10-03 17:22:41 +07:00
1 parent 6093442bcf
commit 9bc3a5db77
10 files changed
+150 -113

No files matched your search

@@ -0,0 +1,11 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {executeAlertsConfigReplacement} from "./alerts-config-execution.js"
import {normalizeAlertsConfig} from "@mmapp/contracts/alerts"
const initial={groups:[{id:"g",name:"Group",combineMode:"all" as const,enabled:false,cooldownOverride:null}],rules:[]}
function fixture(){let state=structuredClone(initial),locks:boolean|undefined=true,failWrite=false,badRead=false,wrongRead=false;const calls:string[]=[];return {calls,state:()=>state,conflict:()=>locks=false,unknown:()=>locks=undefined,fail:()=>failWrite=true,bad:()=>badRead=true,wrong:()=>wrongRead=true,deps:{transaction:async(work:(context:typeof initial)=>Promise<unknown>)=>{calls.push("begin");const context=structuredClone(state);try{const receipt=await work(context);state=context;calls.push("commit");return receipt as never}catch(error){calls.push("rollback");throw error}},lock:async()=>{calls.push("lock");return locks},groups:async(context:typeof initial)=>{calls.push("groups");return context.groups},write:async(context:typeof initial,value:ReturnType<typeof normalizeAlertsConfig>)=>{calls.push("write");context.groups=value.groups!;if(failWrite)throw Error("insert failed")},read:async(context:typeof initial)=>{calls.push("read");return badRead?{ok:false}:wrongRead?{ok:true,...context,groups:initial.groups}:{ok:true,...context}}}}}
test("omitted groups are read and confirmed inside one locked transaction",async()=>{const f=fixture();const receipt=await executeAlertsConfigReplacement({rules:[]},f.deps);assert.equal(receipt.ok,true);assert.deepEqual(receipt.groups,initial.groups);assert.deepEqual(f.calls,["begin","lock","groups","write","read","commit"])})
test("write/readback failures roll back and never repeat",async()=>{for(const step of ["write","read"]){const f=fixture();if(step==="write")f.fail();else f.bad();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps));assert.deepEqual(f.state(),initial);assert.equal(f.calls.filter(s=>s==="write").length,1);assert.equal(f.calls.includes("commit"),false);assert.equal(f.calls.at(-1),"rollback")}})
test("false or unknown lock prevents every destructive write",async()=>{for(const unknown of [false,true]){const f=fixture();if(unknown)f.unknown();else f.conflict();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps),/выполняется|не подтверждена/);assert.equal(f.calls.includes("write"),false);assert.deepEqual(f.state(),initial)}})
test("literal success with another config rolls back instead of confirming unrelated data",async()=>{const f=fixture();f.wrong();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps),/не совпадают/);assert.deepEqual(f.state(),initial);assert.equal(f.calls.at(-1),"rollback")})
@@ -0,0 +1,20 @@
import {normalizeAlertsConfig,alertsRulesReceiptSchema,type PutRulesBody} from "@mmapp/contracts/alerts"
type Receipt=ReturnType<typeof alertsRulesReceiptSchema.parse>
type Normalized=ReturnType<typeof normalizeAlertsConfig>&{groups:NonNullable<ReturnType<typeof normalizeAlertsConfig>["groups"]>}
interface Dependencies<C>{transaction:(work:(context:C)=>Promise<Receipt>)=>Promise<Receipt>;lock:(context:C)=>Promise<boolean|undefined>;groups:(context:C)=>Promise<Normalized["groups"]>;write:(context:C,value:Normalized)=>Promise<void>;read:(context:C)=>Promise<unknown>}
export async function executeAlertsConfigReplacement<C>(input:PutRulesBody,deps:Dependencies<C>):Promise<Receipt>{
normalizeAlertsConfig(input)
return deps.transaction(async context=>{
const locked=await deps.lock(context)
if(locked===false)throw Error("Сохранение правил уже выполняется")
if(locked!==true)throw Error("Блокировка правил не подтверждена")
const normalized=normalizeAlertsConfig({...input,groups:input.groups??await deps.groups(context)}) as Normalized
await deps.write(context,normalized)
const parsed=alertsRulesReceiptSchema.safeParse(await deps.read(context))
if(!parsed.success)throw Error("Сохранённая конфигурация оповещений не подтверждена")
const receipt=parsed.data
const match=(expected:Record<string,unknown>,actual:Record<string,unknown>|undefined)=>actual!==undefined&&Object.entries(expected).every(([key,value])=>JSON.stringify(actual[key])===JSON.stringify(value))
if(receipt.rules.length!==normalized.rules.length||receipt.groups.length!==normalized.groups.length||normalized.rules.some(rule=>!match(rule,receipt.rules.find(row=>row.id===rule.id)))||normalized.groups.some(group=>!match(group,receipt.groups.find(row=>row.id===group.id))))throw Error("Сохранённые правила или группы не совпадают с отправленными")
return receipt
})
}
+97 -96
View File
@@ -1,4 +1,4 @@
import {normalizeAlertsConfig} from "@mmapp/contracts/alerts"
import {executeAlertsConfigReplacement} from "./alerts-config-execution.js"
import {sendTelegramRequest} from "./alerts-telegram-send.js"
import {executeTelegramSettingsUpdate} from "./alerts-telegram-settings.js"
import { asc, desc, eq, sql } from "drizzle-orm"
@@ -67,22 +67,20 @@ async function ensureTelegramRow() {
return row!
}
async function lastFiredByRuleId(): Promise<Map<string, string>> {
const rows = await dbAll<{ ruleId: string; mx: string }>(
`SELECT rule_id AS "ruleId", MAX(fired_at) AS mx
FROM alert_history
WHERE rule_id IS NOT NULL AND rule_id != ''
GROUP BY rule_id`,
)
type AlertsReadContext=Pick<typeof db,"select"|"execute">
async function lastFiredByRuleId(context:AlertsReadContext=db): Promise<Map<string, string>> {
const result = await context.execute(sql`SELECT rule_id AS "ruleId", MAX(fired_at) AS mx FROM alert_history WHERE rule_id IS NOT NULL AND rule_id != '' GROUP BY rule_id`)
const rows = result.rows as {ruleId:string;mx:string|Date}[]
const m = new Map<string, string>()
for (const r of rows) {
if (r.ruleId && r.mx) m.set(r.ruleId, r.mx)
if (r.ruleId && r.mx) m.set(r.ruleId, r.mx instanceof Date?r.mx.toISOString():r.mx)
}
return m
}
async function targetsByRuleId(): Promise<Map<string, string[]>> {
const rows = await db
async function targetsByRuleId(context:AlertsReadContext=db): Promise<Map<string, string[]>> {
const rows = await context
.select()
.from(alertRuleTargets)
.orderBy(asc(alertRuleTargets.sortIndex), asc(alertRuleTargets.id))
@@ -96,8 +94,8 @@ async function targetsByRuleId(): Promise<Map<string, string[]>> {
return m
}
async function conditionsByRuleId(): Promise<Map<string, string[]>> {
const rows = await db
async function conditionsByRuleId(context:AlertsReadContext=db): Promise<Map<string, string[]>> {
const rows = await context
.select()
.from(alertRuleConditions)
.orderBy(asc(alertRuleConditions.sortIndex), asc(alertRuleConditions.id))
@@ -111,8 +109,8 @@ async function conditionsByRuleId(): Promise<Map<string, string[]>> {
return m
}
export async function listAlertGroups(): Promise<ApiAlertGroup[]> {
const rows = await db
export async function listAlertGroups(context:AlertsReadContext=db): Promise<ApiAlertGroup[]> {
const rows = await context
.select()
.from(alertGroups)
.orderBy(asc(alertGroups.name))
@@ -125,11 +123,11 @@ export async function listAlertGroups(): Promise<ApiAlertGroup[]> {
}))
}
export async function listAlertRules(): Promise<ApiAlertRule[]> {
const lf = await lastFiredByRuleId()
const tgtMap = await targetsByRuleId()
const condMap = await conditionsByRuleId()
const rows = await db.select().from(alertRules)
export async function listAlertRules(context:AlertsReadContext=db): Promise<ApiAlertRule[]> {
const lf = await lastFiredByRuleId(context)
const tgtMap = await targetsByRuleId(context)
const condMap = await conditionsByRuleId(context)
const rows = await context.select().from(alertRules)
return rows.map((r) => {
const targets = tgtMap.get(r.id)
const ts = targets?.length ? targets : [r.target]
@@ -205,91 +203,94 @@ export type ReplaceAlertGroupInput = {
}
/** Атомарно заменяет группы, правила и строки targets. */
export async function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
const now = sql`now()`
const normalized = normalizeAlertsConfig(payload)
const groups = normalized.groups!
const rules = normalized.rules
await db.transaction(async (tx) => {
await tx.delete(alertRuleConditions)
await tx.delete(alertRuleTargets)
await tx.delete(alertRules)
await tx.delete(alertGroups)
for (const g of groups) {
await tx.insert(alertGroups).values({
id: g.id,
name: g.name.trim() || g.id,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
updatedAt: now,
})
}
for (const r of rules) {
const rawTargets =
r.targets && r.targets.length > 0
? r.targets.map((t) => t.trim()).filter(Boolean)
: [String(r.target ?? "").trim()].filter(Boolean)
const summary = summarizeTargets(rawTargets.length ? rawTargets : ["—"])
const rawConds =
r.conditions && r.conditions.length > 0
? r.conditions.map((c) => c.trim()).filter(Boolean)
: [String(r.condition ?? "").trim()].filter(Boolean)
const condSummary = summarizeConditionLines(rawConds.length ? rawConds : ["—"])
const stab =
r.confirmStabilitySec != null &&
Number.isFinite(r.confirmStabilitySec) &&
r.confirmStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.confirmStabilitySec)))
: null
const recoveryMode =
r.recoveryMode === "never" || r.recoveryMode === "conditional" ? r.recoveryMode : "always"
const recoveryStabilitySec =
r.recoveryStabilitySec != null &&
Number.isFinite(r.recoveryStabilitySec) &&
r.recoveryStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.recoveryStabilitySec)))
: null
await tx.insert(alertRules).values({
id: r.id,
name: r.name,
type: r.type,
target: summary || "—",
condition: condSummary,
severity: r.severity,
enabled: r.enabled,
cooldown: r.cooldown,
confirmStabilitySec: stab,
recoveryMode,
recoveryStabilitySec,
chatId: r.chatId ?? "",
groupId: r.groupId && r.groupId.trim() ? r.groupId.trim() : null,
updatedAt: now,
})
for (const [i, t] of rawTargets.entries()) {
await tx.insert(alertRuleTargets).values({
id: `rt-${r.id}-${i}`,
ruleId: r.id,
target: t,
sortIndex: i,
type AlertsWriteContext=Parameters<Parameters<typeof db.transaction>[0]>[0]
export async function replaceAlertsConfig(payload: { groups?: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
const now=sql`now()`
return executeAlertsConfigReplacement<AlertsWriteContext>(payload, {
transaction:work=>db.transaction(work),
lock:async tx=>{const result=await tx.execute(sql`SELECT pg_try_advisory_xact_lock(8723103) AS acquired`);return typeof result.rows[0]?.acquired==="boolean"?result.rows[0].acquired:undefined},
groups:tx=>listAlertGroups(tx),
write:async(tx,{groups,rules})=>{
await tx.delete(alertRuleConditions)
await tx.delete(alertRuleTargets)
await tx.delete(alertRules)
await tx.delete(alertGroups)
for (const g of groups) {
await tx.insert(alertGroups).values({
id: g.id,
name: g.name.trim() || g.id,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
updatedAt: now,
})
}
for (const [i, c] of rawConds.entries()) {
await tx.insert(alertRuleConditions).values({
id: `rc-${r.id}-${i}`,
ruleId: r.id,
conditionLine: c,
sortIndex: i,
for (const r of rules) {
const rawTargets =
r.targets && r.targets.length > 0
? r.targets.map((t) => t.trim()).filter(Boolean)
: [String(r.target ?? "").trim()].filter(Boolean)
const summary = summarizeTargets(rawTargets.length ? rawTargets : ["—"])
const rawConds =
r.conditions && r.conditions.length > 0
? r.conditions.map((c) => c.trim()).filter(Boolean)
: [String(r.condition ?? "").trim()].filter(Boolean)
const condSummary = summarizeConditionLines(rawConds.length ? rawConds : ["—"])
const stab =
r.confirmStabilitySec != null &&
Number.isFinite(r.confirmStabilitySec) &&
r.confirmStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.confirmStabilitySec)))
: null
const recoveryMode =
r.recoveryMode === "never" || r.recoveryMode === "conditional" ? r.recoveryMode : "always"
const recoveryStabilitySec =
r.recoveryStabilitySec != null &&
Number.isFinite(r.recoveryStabilitySec) &&
r.recoveryStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.recoveryStabilitySec)))
: null
await tx.insert(alertRules).values({
id: r.id,
name: r.name,
type: r.type,
target: summary || "—",
condition: condSummary,
severity: r.severity,
enabled: r.enabled,
cooldown: r.cooldown,
confirmStabilitySec: stab,
recoveryMode,
recoveryStabilitySec,
chatId: r.chatId ?? "",
groupId: r.groupId && r.groupId.trim() ? r.groupId.trim() : null,
updatedAt: now,
})
for (const [i, t] of rawTargets.entries()) {
await tx.insert(alertRuleTargets).values({
id: `rt-${r.id}-${i}`,
ruleId: r.id,
target: t,
sortIndex: i,
})
}
for (const [i, c] of rawConds.entries()) {
await tx.insert(alertRuleConditions).values({
id: `rc-${r.id}-${i}`,
ruleId: r.id,
conditionLine: c,
sortIndex: i,
})
}
}
}
},
read:async tx=>({ok:true,groups:await listAlertGroups(tx),rules:await listAlertRules(tx)}),
})
}
/** Совместимость: только правила, группы не трогаем. */
export async function replaceAlertRules(rules: ReplaceAlertRuleInput[]) {
const curGroups = await listAlertGroups()
await replaceAlertsConfig({ groups: curGroups.map((g) => ({ ...g })), rules })
await replaceAlertsConfig({ rules })
}
export async function getTelegramSettingsRow() {