From b1ad9515fadafd920e2a9a7c4a6f3a485d17240f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 18:56:08 +0700 Subject: [PATCH] =?UTF-8?q?fix(alerts):=20=D1=83=D0=B4=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B5=D0=B8=D0=B7=D0=B2?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BE=D1=82=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=B1=D0=B5=D0=B7=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drizzle/0008_alert_outbox_outcomes.sql | 6 + backend/package.json | 2 +- backend/src/db/schema.ts | 2 +- .../alert-engine/outbox-execution.test.ts | 33 ++++ .../services/alert-engine/outbox-execution.ts | 35 ++++ backend/src/services/alert-engine/outbox.ts | 164 ++++-------------- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 12 +- docs/ux-ui-stage-11b.md | 5 + 9 files changed, 128 insertions(+), 133 deletions(-) create mode 100644 backend/drizzle/0008_alert_outbox_outcomes.sql create mode 100644 backend/src/services/alert-engine/outbox-execution.test.ts create mode 100644 backend/src/services/alert-engine/outbox-execution.ts diff --git a/backend/drizzle/0008_alert_outbox_outcomes.sql b/backend/drizzle/0008_alert_outbox_outcomes.sql new file mode 100644 index 00000000..60ebf17b --- /dev/null +++ b/backend/drizzle/0008_alert_outbox_outcomes.sql @@ -0,0 +1,6 @@ +-- Захват перед POST и удержание неизвестных исходов. Исторические строки не меняются. +BEGIN; +ALTER TABLE alert_outbox DROP CONSTRAINT IF EXISTS alert_outbox_status_check; +ALTER TABLE alert_outbox ADD CONSTRAINT alert_outbox_status_check + CHECK (status IN ('pending', 'processing', 'unknown', 'sent', 'failed')); +COMMIT; diff --git a/backend/package.json b/backend/package.json index 521933da..ece4a8fe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "npm run build -w @mmapp/contracts && tsc", - "test:alerts-telegram": "tsx src/services/alerts-telegram-delivery.test.ts && tsx src/services/alerts-revision.test.ts && tsx src/services/alerts-overview.test.ts && tsx ../packages/contracts/src/alerts-normalization.test.ts && tsx src/services/alerts-config-execution.test.ts && tsx src/services/alerts-telegram-settings.test.ts && tsx src/services/alerts-telegram-send.test.ts && tsx src/routes/alerts.test.ts && tsx src/plugins/auth.alerts.test.ts", + "test:alerts-telegram": "tsx src/services/alert-engine/outbox-execution.test.ts && tsx src/services/alerts-telegram-delivery.test.ts && tsx src/services/alerts-revision.test.ts && tsx src/services/alerts-overview.test.ts && tsx ../packages/contracts/src/alerts-normalization.test.ts && tsx src/services/alerts-config-execution.test.ts && tsx src/services/alerts-telegram-settings.test.ts && tsx src/services/alerts-telegram-send.test.ts && tsx src/routes/alerts.test.ts && tsx src/plugins/auth.alerts.test.ts", "test:alert-engine": "tsx src/services/alert-engine/groups.test.ts && tsx src/services/alert-engine/evaluate-rule.test.ts && tsx src/services/alert-engine/confirm-stability.test.ts", "start": "node dist/index.js", "db:generate": "drizzle-kit generate", diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index acc02018..62d5f4a8 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -606,7 +606,7 @@ export const alertOutbox = pgTable("alert_outbox", { id: text("id").primaryKey(), dedupeKey: text("dedupe_key").notNull(), channel: text("channel", { enum: ["telegram"] }).notNull().default("telegram"), - status: text("status", { enum: ["pending", "sent", "failed"] }).notNull().default("pending"), + status: text("status", { enum: ["pending", "processing", "unknown", "sent", "failed"] }).notNull().default("pending"), retryCount: integer("retry_count").notNull().default(0), maxRetries: integer("max_retries").notNull().default(3), nextAttemptAt: ts("next_attempt_at").notNull(), diff --git a/backend/src/services/alert-engine/outbox-execution.test.ts b/backend/src/services/alert-engine/outbox-execution.test.ts new file mode 100644 index 00000000..e495b19b --- /dev/null +++ b/backend/src/services/alert-engine/outbox-execution.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {executeOutboxDelivery,type OutboxRow,type OutboxSettlement} from "./outbox-execution.js" +import type {TelegramSendResult} from "../alerts-telegram-send.js" +const payload={text:"Fixture",history:{id:"h",ruleName:"Rule",severity:"critical",message:"Fixture",firedAt:"2026-10-03T00:00:00.000Z"}} +function fixture(result:TelegramSendResult={ok:true}){ + let status="pending",sends=0,failAccepted=false,failUnknown=false;const history:unknown[]=[],settlements:OutboxSettlement[]=[] + const row:OutboxRow={id:"o",retryCount:0,maxRetries:3,payloadJson:payload} + return {row,history,settlements,status:()=>status,sends:()=>sends,failReceipt:()=>failAccepted=true,failHold:()=>failUnknown=true,deps:{claim:async()=>{if(status!=="pending")return undefined;status="processing";return {...row}},now:()=>Date.parse("2026-10-03T00:00:00.000Z"),send:async()=>{sends++;return result},settle:async(_id:string,value:OutboxSettlement)=>{if(status!=="processing")throw Error("not owned");if(value.status==="sent"&&failAccepted||value.status==="unknown"&&failUnknown)throw Error("receipt DB failure");settlements.push(value);status=value.status;row.retryCount=value.retryCount??row.retryCount;if(value.history)history.push(value.history)}}} +} +test("parallel dispatch claims a message once and writes one confirmed history",async()=>{ + const f=fixture();const results=await Promise.all([executeOutboxDelivery("o",f.deps),executeOutboxDelivery("o",f.deps)]);assert.deepEqual(results.map(r=>r.kind),["sent","skipped"]);assert.equal(f.sends(),1);assert.equal(f.status(),"sent");assert.equal(f.history.length,1) +}) +test("network unknown is held without success history and cannot automatically resend",async()=>{ + const f=fixture({ok:false,outcome:"unknown",error:"response missing"});assert.equal((await executeOutboxDelivery("o",f.deps)).kind,"held");assert.equal(f.status(),"unknown");assert.equal(f.history.length,0);assert.equal((await executeOutboxDelivery("o",f.deps)).kind,"skipped");assert.equal(f.sends(),1) +}) +test("accepted API with failed transactional receipt stays held and is not falsely marked failed",async()=>{ + const f=fixture();f.failReceipt();const result=await executeOutboxDelivery("o",f.deps);assert.equal(result.kind,"held");assert.equal(result.apiAccepted,true);assert.equal(f.status(),"unknown");assert.equal(f.history.length,0);await executeOutboxDelivery("o",f.deps);assert.equal(f.sends(),1) +}) +test("failure to persist unknown leaves processing held across another worker turn",async()=>{ + const f=fixture({ok:false,outcome:"unknown",error:"response missing"});f.failHold();assert.equal((await executeOutboxDelivery("o",f.deps)).kind,"held");assert.equal(f.status(),"processing");assert.equal((await executeOutboxDelivery("o",f.deps)).kind,"skipped");assert.equal(f.sends(),1) +}) +test("confirmed rejection preserves bounded retry/backoff then writes failed history only when exhausted",async()=>{ + const f=fixture({ok:false,outcome:"rejected",error:"Telegram rejected"});assert.equal((await executeOutboxDelivery("o",f.deps)).kind,"retry");assert.equal(f.history.length,0);assert.equal(f.row.retryCount,1);assert.equal(f.settlements[0]?.nextAttemptAt,"2026-10-03T00:00:15.000Z");await executeOutboxDelivery("o",f.deps);assert.equal((await executeOutboxDelivery("o",f.deps)).kind,"failed");assert.equal(f.row.retryCount,3);assert.equal(f.history.length,1);assert.equal((f.history[0] as {sentOk:boolean}).sentOk,false);assert.equal(f.sends(),3) +}) +test("malformed payload and invalid counters are rejected before Telegram POST",async()=>{ + for(const value of [null,{},[],{...payload,text:""},{...payload,history:null},{...payload,history:{...payload.history,firedAt:"invalid"}}]){const f=fixture();f.row.payloadJson=value;assert.equal((await executeOutboxDelivery("o",f.deps)).kind,"failed");assert.equal(f.sends(),0)} + const invalid=fixture();invalid.row.maxRetries=0;assert.equal((await executeOutboxDelivery("o",invalid.deps)).kind,"failed");assert.equal(invalid.sends(),0) +}) +test("claim failure never starts POST; unexpected send receipt is held",async()=>{ + let sends=0;const result=await executeOutboxDelivery("o",{claim:async()=>{throw Error("DB failed")},send:async()=>{sends++;return {ok:true}},settle:async()=>{},now:Date.now});assert.equal(result.kind,"held");assert.equal(sends,0) + const f=fixture({} as TelegramSendResult);assert.equal((await executeOutboxDelivery("o",f.deps)).kind,"held");assert.equal(f.status(),"unknown");assert.equal(f.sends(),1) +}) diff --git a/backend/src/services/alert-engine/outbox-execution.ts b/backend/src/services/alert-engine/outbox-execution.ts new file mode 100644 index 00000000..31d91b73 --- /dev/null +++ b/backend/src/services/alert-engine/outbox-execution.ts @@ -0,0 +1,35 @@ +import {z} from "zod" +import type {TelegramSendResult} from "../alerts-telegram-send.js" +const payloadSchema=z.object({text:z.string().min(1),chatId:z.string().optional(),history:z.object({id:z.string().min(1),ruleId:z.string().nullable().optional(),groupId:z.string().nullable().optional(),ruleName:z.string(),severity:z.enum(["critical","warning","info"]),message:z.string(),firedAt:z.string().refine(value=>Number.isFinite(Date.parse(value)))}).strict()}).strict() +export type AlertOutboxPayload=z.output +export type OutboxRow={id:string;retryCount:number;maxRetries:number;payloadJson:unknown} +export type OutboxSettlement={status:"pending"|"sent"|"failed"|"unknown";retryCount?:number;nextAttemptAt?:string;sentAt?:string;lastError:string|null;history?:AlertOutboxPayload["history"]&{sentOk:boolean}} +type Result={kind:"sent"|"failed"|"retry"|"held"|"skipped";apiAccepted?:true;error?:string} +interface Dependencies{claim:(id:string)=>Promise;send:(payload:AlertOutboxPayload)=>Promise;settle:(id:string,value:OutboxSettlement)=>Promise;now:()=>number} +export async function executeOutboxDelivery(id:string,deps:Dependencies):Promise{ + let row:OutboxRow|undefined + try{row=await deps.claim(id)}catch{return {kind:"held",error:"Не подтверждён захват сообщения; отправка не начата"}} + if(!row)return {kind:"skipped"} + if(row.id!==id)return {kind:"held",error:"Захвачено другое сообщение; отправка не начата"} + async function hold(error:string,apiAccepted?:true):Promise{ + try{await deps.settle(id,{status:"unknown",lastError:error})}catch{return {kind:"held",apiAccepted,error:error+". Сохранение статуса не подтверждено; processing не повторяется автоматически"}} + return {kind:"held",apiAccepted,error} + } + let raw=row.payloadJson + if(typeof raw==="string"){try{raw=JSON.parse(raw)}catch{raw=null}} + const parsed=payloadSchema.safeParse(raw) + if(!parsed.success||!Number.isInteger(row.retryCount)||row.retryCount<0||!Number.isInteger(row.maxRetries)||row.maxRetries<1){ + try{await deps.settle(id,{status:"failed",lastError:"Некорректная запись очереди"});return {kind:"failed"}}catch{return hold("Не удалось отклонить некорректную запись очереди")} + } + const payload=parsed.data + let send:TelegramSendResult + try{send=await deps.send(payload)}catch{return hold("Результат отправки неизвестен: ответ не получен")} + if(send?.ok===true){ + try{await deps.settle(id,{status:"sent",sentAt:new Date(deps.now()).toISOString(),lastError:null,history:{...payload.history,sentOk:true}});return {kind:"sent",apiAccepted:true}} + catch{return hold("Telegram API принял сообщение, но запись результата в БД не подтверждена",true)} + } + if(send?.ok!==false||send.outcome!=="rejected")return hold(send?.ok===false?send.error:"Telegram не подтвердил результат отправки") + const retryCount=row.retryCount+1,exhausted=retryCount>=row.maxRetries + try{await deps.settle(id,{retryCount,status:exhausted?"failed":"pending",nextAttemptAt:new Date(deps.now()+Math.min(300_000,retryCount*15_000)).toISOString(),lastError:send.error,...(exhausted?{history:{...payload.history,sentOk:false}}:{})});return {kind:exhausted?"failed":"retry",error:send.error}} + catch{return hold("Отказ Telegram получен, но запись следующей попытки не подтверждена")} +} diff --git a/backend/src/services/alert-engine/outbox.ts b/backend/src/services/alert-engine/outbox.ts index 0712971b..6368a2cc 100644 --- a/backend/src/services/alert-engine/outbox.ts +++ b/backend/src/services/alert-engine/outbox.ts @@ -1,134 +1,40 @@ -import { and, asc, eq, lte } from "drizzle-orm" -import { db } from "../../db/index.js" -import { alertOutbox } from "../../db/schema.js" -import { appendAlertHistory, sendTelegramAlertMessage } from "../alerts-service.js" +import {and,asc,eq,lte} from "drizzle-orm" +import {db} from "../../db/index.js" +import {alertOutbox,alertHistory} from "../../db/schema.js" +import {sendTelegramAlertMessage} from "../alerts-service.js" +import {executeOutboxDelivery,type AlertOutboxPayload} from "./outbox-execution.js" +export type {AlertOutboxPayload} from "./outbox-execution.js" -export type AlertOutboxPayload = { - text: string - chatId?: string - history: { - id: string - ruleId?: string | null - groupId?: string | null - ruleName: string - severity: "critical" | "warning" | "info" - message: string - firedAt: string - } +export async function enqueueTelegramOutbox(input:{id:string;dedupeKey:string;payload:AlertOutboxPayload;maxRetries?:number}):Promise{ + const [receipt]=await db.insert(alertOutbox).values({id:input.id,dedupeKey:input.dedupeKey,channel:"telegram",status:"pending",retryCount:0,maxRetries:Math.max(1,Math.floor(input.maxRetries??3)),nextAttemptAt:new Date().toISOString(),payloadJson:input.payload}).onConflictDoNothing({target:alertOutbox.dedupeKey}).returning({id:alertOutbox.id}) + if(!receipt)return false + if(receipt.id!==input.id)throw Error("Запись сообщения в очередь не подтверждена") + return true } -function safeParsePayload(raw: unknown): AlertOutboxPayload | null { - if (raw && typeof raw === "object") return raw as AlertOutboxPayload - if (typeof raw !== "string") return null - try { - return JSON.parse(raw) as AlertOutboxPayload - } catch { - return null - } -} - -export async function enqueueTelegramOutbox(input: { - id: string - dedupeKey: string - payload: AlertOutboxPayload - maxRetries?: number -}): Promise { - const existing = (await db - .select() - .from(alertOutbox) - .where(eq(alertOutbox.dedupeKey, input.dedupeKey)) - .limit(1))[0] - if (existing && (existing.status === "pending" || existing.status === "sent")) return false - const nowIso = new Date().toISOString() - await db.insert(alertOutbox) - .values({ - id: input.id, - dedupeKey: input.dedupeKey, - channel: "telegram", - status: "pending", - retryCount: 0, - maxRetries: Math.max(1, Math.floor(input.maxRetries ?? 3)), - nextAttemptAt: nowIso, - payloadJson: input.payload, - }) - return true -} - -export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; failed: number; errors: string[] }> { - return (async () => { - const nowIso = new Date().toISOString() - const rows = await db - .select() - .from(alertOutbox) - .where(and(eq(alertOutbox.status, "pending"), lte(alertOutbox.nextAttemptAt, nowIso))) - .orderBy(asc(alertOutbox.createdAt)) - .limit(Math.max(1, limit)) - - let sent = 0 - let failed = 0 - const errors: string[] = [] - - for (const row of rows) { - const payload = safeParsePayload(row.payloadJson) - if (!payload) { - await db.update(alertOutbox) - .set({ - status: "failed", - lastError: "invalid payload_json", - }) - .where(eq(alertOutbox.id, row.id)) - failed += 1 - continue - } - const send = await sendTelegramAlertMessage({ text: payload.text, chatId: payload.chatId }) - if (send.ok) { - await db.update(alertOutbox) - .set({ - status: "sent", - sentAt: new Date().toISOString(), - lastError: null, - }) - .where(eq(alertOutbox.id, row.id)) - await appendAlertHistory({ - id: payload.history.id, - ruleId: payload.history.ruleId ?? null, - groupId: payload.history.groupId ?? null, - ruleName: payload.history.ruleName, - severity: payload.history.severity, - message: payload.history.message, - sentOk: true, - firedAt: payload.history.firedAt, - }) - sent += 1 - continue - } - const nextRetry = row.retryCount + 1 - const exhausted = nextRetry >= row.maxRetries - await db.update(alertOutbox) - .set({ - retryCount: nextRetry, - status: exhausted ? "failed" : "pending", - nextAttemptAt: new Date(Date.now() + Math.min(300_000, nextRetry * 15_000)).toISOString(), - lastError: send.error, - }) - .where(eq(alertOutbox.id, row.id)) - if (exhausted) { - await appendAlertHistory({ - id: payload.history.id, - ruleId: payload.history.ruleId ?? null, - groupId: payload.history.groupId ?? null, - ruleName: payload.history.ruleName, - severity: payload.history.severity, - message: payload.history.message, - sentOk: false, - firedAt: payload.history.firedAt, - }) - failed += 1 - } else { - errors.push(`outbox ${row.id}: ${send.error}`) - } +export async function dispatchPendingOutbox(limit=25):Promise<{sent:number;failed:number;held:number;errors:string[]}>{ + const nowIso=new Date().toISOString() + const rows=await db.select({id:alertOutbox.id}).from(alertOutbox).where(and(eq(alertOutbox.status,"pending"),lte(alertOutbox.nextAttemptAt,nowIso))).orderBy(asc(alertOutbox.createdAt)).limit(Math.max(1,limit)) + let sent=0,failed=0,held=0;const errors:string[]=[] + for(const row of rows){ + const result=await executeOutboxDelivery(row.id,{ + now:Date.now, + claim:async id=>{const [claimed]=await db.update(alertOutbox).set({status:"processing",nextAttemptAt:new Date().toISOString()}).where(and(eq(alertOutbox.id,id),eq(alertOutbox.status,"pending"),lte(alertOutbox.nextAttemptAt,new Date().toISOString()))).returning();if(claimed&&(claimed.id!==id||claimed.status!=="processing"))throw Error("Захват сообщения не подтверждён");return claimed}, + send:payload=>sendTelegramAlertMessage({text:payload.text,chatId:payload.chatId}), + settle:(id,value)=>db.transaction(async tx=>{ + const {history,...patch}=value + const [receipt]=await tx.update(alertOutbox).set(patch).where(and(eq(alertOutbox.id,id),eq(alertOutbox.status,"processing"))).returning({id:alertOutbox.id,status:alertOutbox.status}) + if(!receipt||receipt.id!==id||receipt.status!==value.status)throw Error("Статус очереди не подтверждён") + if(history){ + const [saved]=await tx.insert(alertHistory).values({...history,ruleId:history.ruleId??null,groupId:history.groupId??null}).returning() + if(!saved||saved.id!==history.id||saved.sentOk!==history.sentOk||saved.ruleName!==history.ruleName||saved.severity!==history.severity||saved.message!==history.message||saved.ruleId!==(history.ruleId??null)||saved.groupId!==(history.groupId??null)||Date.parse(saved.firedAt)!==Date.parse(history.firedAt))throw Error("Журнал результата очереди не подтверждён") } - - return { sent, failed, errors } - })() + }), + }) + if(result.apiAccepted)sent++ + if(result.kind==="failed")failed++ + if(result.kind==="held")held++ + if(result.error)errors.push("outbox "+row.id+": "+result.error) + } + return {sent,failed,held,errors} } diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index bf1ed693..852a7b63 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.1; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3.2: миграция статусов outbox, atomic claim перед POST и unknown hold без автоматического resend; затем согласованный receipt/history и UI очереди, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. +Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3.3: показать known очередь/processing/unknown в GET/UI и сохранить состояние при сбое; затем контролируемое разрешение held entries, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index a4102b34..3933e25b 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.3.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.3.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,16 @@ +## Блок 11Б.4.4.3.2 — atomic outbox claim и unknown hold (2026-10-03) + +- Перед POST dispatcher выполняет conditional UPDATE pending+due→processing RETURNING и проверяет ownership/id/status. Второй worker и stale selected row не могут отправить ту же запись через новый dispatcher. Claim exception не запускает POST. Payload/history/counters проверяются до отправки. +- Unknown/timeout/malformed send удерживается как unknown без automatic resend; crash/DB failure оставляет processing, который также не выбирается заново. API accepted + failed DB receipt не становится failed и не повторяется. Sent/failed status и history записываются одной transaction с returning checks; отсутствие receipt отклоняет callback. +- Подтверждённый rejection сохраняет прежние maxRetries/backoff. Enqueue теперь атомарный onConflictDoNothing RETURNING по dedupeKey вместо select-then-insert race; existing failed/held не переотправляются скрыто. Cooldown/recovery/evaluation не менялись. Dispatch sent — число API acceptance, held/errors отдельно отражают failed persistence. +- Новая 0008 migration атомарно расширяет реальный PG status CHECK (processing/unknown); исторические SQL не менялись. Bootstrap обнаруживает файл через applySqlMigrations. Migration не применялась локально: PG недоступен. При выпуске обновить все dispatcher workers вместе; старый worker не использует claim. Не откатывать CHECK при held rows. +- 7 executor DI, Alerts backend/HTTP/JWT профиль, backend build/lint прошли. Engine groups/cooldown/evaluate прошли; confirm-stability пропущен с ECONNREFUSED PG. DI доказывает orchestration, не реальный PG lock/rollback/migration/crash recovery/Telegram delivery. +- Очередь ещё не принята: processing/unknown пока не видны в Alerts UI, ручное разрешение held entries пока отсутствует. Консервативный hold требует операторской проверки; автоматически считать старый processing неотправленным нельзя. +- Следующий точный шаг 11Б.4.4.3.3: показать known очередь/processing/unknown в GET/UI и сохранить состояние при сбое; затем контролируемое разрешение held entries, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. + ## Блок 11Б.4.4.3.1 — классификация Telegram delivery (2026-10-03) - Telegram send helper отличает rejected (валидный negative Bot API receipt с ok:false/error_code/description) от unknown (network/timeout/malformed/HTTP failure без согласованного negative receipt). HTTP success+literal ok:true остаётся API acceptance; не доказательство доставки. Один bounded POST, без retry; секреты redacted. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 1a612615..314cec69 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -283,3 +283,8 @@ Canonical configuration hash, обязательный If-Match HTTP, locked sta ### 11Б.4.4.3.1 — delivery outcome (2026-10-03) Bot API rejected/unknown, единый stored snapshot/strict thread, no secret/no retry в helper. 3 delivery + 4 send DI, Alerts profile/backend build/lint прошли; dispatcher ещё не мигрирован, PG/Telegram не проверены. Далее 11Б.4.4.3.2: миграция статусов outbox, atomic claim перед POST и unknown hold без автоматического resend; затем согласованный receipt/history и UI очереди, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. + + +### 11Б.4.4.3.2 — outbox orchestration (2026-10-03) + +Conditional atomic claim, unknown/processing hold, transactional status/history receipt, atomic dedupe enqueue и migration 0008. 7 DI/profile/build/lint прошли; engine groups/eval прошли, PG stability skipped. Реальные migration/lock/rollback/Telegram и UI/operator resolution ещё не приняты. Далее 11Б.4.4.3.3: показать known очередь/processing/unknown в GET/UI и сохранить состояние при сбое; затем контролируемое разрешение held entries, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.