fix(alerts): удерживать неизвестные отправки без автоматического повтора
This commit is contained in:
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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 получен, но запись следующей попытки не подтверждена")}
|
||||
}
|
||||
@@ -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<boolean>{
|
||||
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<boolean> {
|
||||
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}
|
||||
}
|
||||
+1
-1
@@ -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).
|
||||
|
||||
|
||||
+11
-1
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user