fix(alerts): удерживать неизвестные отправки без автоматического повтора

This commit is contained in:
Denozordec committed 2026-10-03 18:56:08 +07:00
1 parent 46be201d10
commit b1ad9515fa
9 files changed
+128 -133

No files matched your search

@@ -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;
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "npm run build -w @mmapp/contracts && tsc", "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", "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", "start": "node dist/index.js",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
+1 -1
View File
@@ -606,7 +606,7 @@ export const alertOutbox = pgTable("alert_outbox", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
dedupeKey: text("dedupe_key").notNull(), dedupeKey: text("dedupe_key").notNull(),
channel: text("channel", { enum: ["telegram"] }).notNull().default("telegram"), 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), retryCount: integer("retry_count").notNull().default(0),
maxRetries: integer("max_retries").notNull().default(3), maxRetries: integer("max_retries").notNull().default(3),
nextAttemptAt: ts("next_attempt_at").notNull(), nextAttemptAt: ts("next_attempt_at").notNull(),
@@ -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)
})
@@ -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<typeof payloadSchema>
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<OutboxRow|undefined>;send:(payload:AlertOutboxPayload)=>Promise<TelegramSendResult>;settle:(id:string,value:OutboxSettlement)=>Promise<void>;now:()=>number}
export async function executeOutboxDelivery(id:string,deps:Dependencies):Promise<Result>{
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<Result>{
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 получен, но запись следующей попытки не подтверждена")}
}
+35 -129
View File
@@ -1,134 +1,40 @@
import { and, asc, eq, lte } from "drizzle-orm" import {and,asc,eq,lte} from "drizzle-orm"
import { db } from "../../db/index.js" import {db} from "../../db/index.js"
import { alertOutbox } from "../../db/schema.js" import {alertOutbox,alertHistory} from "../../db/schema.js"
import { appendAlertHistory, sendTelegramAlertMessage } from "../alerts-service.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 = { export async function enqueueTelegramOutbox(input:{id:string;dedupeKey:string;payload:AlertOutboxPayload;maxRetries?:number}):Promise<boolean>{
text: string 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})
chatId?: string if(!receipt)return false
history: { if(receipt.id!==input.id)throw Error("Запись сообщения в очередь не подтверждена")
id: string return true
ruleId?: string | null
groupId?: string | null
ruleName: string
severity: "critical" | "warning" | "info"
message: string
firedAt: string
}
} }
function safeParsePayload(raw: unknown): AlertOutboxPayload | null { export async function dispatchPendingOutbox(limit=25):Promise<{sent:number;failed:number;held:number;errors:string[]}>{
if (raw && typeof raw === "object") return raw as AlertOutboxPayload const nowIso=new Date().toISOString()
if (typeof raw !== "string") return null 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))
try { let sent=0,failed=0,held=0;const errors:string[]=[]
return JSON.parse(raw) as AlertOutboxPayload for(const row of rows){
} catch { const result=await executeOutboxDelivery(row.id,{
return null 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=>{
export async function enqueueTelegramOutbox(input: { const {history,...patch}=value
id: string 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})
dedupeKey: string if(!receipt||receipt.id!==id||receipt.status!==value.status)throw Error("Статус очереди не подтверждён")
payload: AlertOutboxPayload if(history){
maxRetries?: number const [saved]=await tx.insert(alertHistory).values({...history,ruleId:history.ruleId??null,groupId:history.groupId??null}).returning()
}): Promise<boolean> { 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("Журнал результата очереди не подтверждён")
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}`)
}
} }
}),
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}
} }
+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-03 ## Актуальная точка продолжения — 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). Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+11 -1
View File
@@ -28,7 +28,7 @@
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 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 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 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) ## Блок 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. - 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.
+5
View File
@@ -283,3 +283,8 @@ Canonical configuration hash, обязательный If-Match HTTP, locked sta
### 11Б.4.4.3.1 — delivery outcome (2026-10-03) ### 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. 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.