Тема
WS Strict Fail-Fast + REST Resync — Design (V1 of WS self-healing)
Реализовано в pm-engine (cutover 2026-07-22). Дизайн (3-классовая strict-parser policy,
ReconnectReason, REST-resync-after-reconnect, 6-метричная таксономия, §5 coverage rule-IDs) — ЖИВОЙ и действует в pm-engine. WS-слой теперь живёт подcrates/pm-engine/src/adapters/orderbook/(market-канал) иcrates/pm-engine/src/adapters/rtds/; отдельных файловio/ws/book.rs/io/ws/user.rs/application/scanner.rs, как в удалённомpm-bot, больше нет. Все ссылки видаcrates/pm-bot/src/io/ws/…:NNN, LOC-дельты,cargo … -p pm-botи §2/§4/§6/§7-план ниже — историко-архивный gap-analysis pm-bot-эры, который МОТИВИРОВАЛ этот дизайн (стартовая точка), а НЕ актуальная карта кода. Не воспроизводимы на текущем дереве и не переиндексируются в pm-engine построчно (иначе — фабрикация якорей).
Status: ACCEPTED — 2026-05-13 (user-ratified V1+V3 architecture; see memory
project_ws_self_healing_decision); IMPLEMENTED в pm-engine 2026-07-22 Implements: backlog #143Complements: BUG-11 fix (V3 — periodic reconciliation pool, merge commit45295cf, closed in96b0ce9) Related ADR: ADR-022 (OrderbookStore::update_from_rest_snapshotreuse for resync) Strategy.md §9 coverage: 13 rule-IDs newly closed; 3 explicitly out of scope.
1. Цель
Закрыть два класса silent failures в WS market channel:
- Parser swallow — unknown event types, malformed payloads, missing fields, и parse-error деградации в
book.rsсейчас тихо превращаются вVec::new()илиcontinue. Любая аномалия должна быть observable (counter + structured log) либо fatal (debug build), но никогда не «исчезать». - Reconnect resync gap — после reconnect WS подписки переотправляются,
invalidate_orderbook_cacheпомечает кеш stale (scanner.rs:510-517), но активный REST resnapshot не запрашивается. Текущий поведение полагается на (a) следующий WS Book frame от Polymarket после resubscribe + initial_dump, (b) фоновый reconciliation pool (BUG-11). Это не гарантирует быстрый close gap'а для всех подписок — initial_dump может занять секунды, reconciliation pool работает в rate-budget. V1 добавляет явный REST burst для всех subscribed token_ids сразу после reconnect.
Принцип (memory feedback_ws_must_be_self_healing): WS parser bugs и missed events — это bugs нашего кода, которые надо чинить fail-fast (panic в dev, structured error + counter в prod). Это не оправдание для downstream защит.
2. Текущее состояние (gap analysis)
2.1 Silent skip locations в crates/pm-bot/src/io/ws/book.rs
Исходный файл: 1868 LOC. После аудита найдено 15 silent-skip веток, требующих перевода в observable failure mode.
| # | Line | Pattern | Current behavior | Risk |
|---|---|---|---|---|
| 1 | book.rs:810 | Err(_) => return Vec::new() в parse_book_event (RawEvent envelope) | Malformed JSON envelope dropped silently | High — корневой parse, скрывает все downstream проблемы |
| 2 | book.rs:826 | other => { debug!(...); Vec::new() } в dispatcher по event_type | Unknown event_type → debug + drop | Critical — Polymarket добавил tick_size_change в 2026, мы не заметили бы новый тип до отдельного капчура |
| 3 | book.rs:833 | sonic_rs::from_str(text).ok()? в parse_book_event | BookEvent shape mismatch → None | High — рассинхрон wire schema |
| 4 | book.rs:839-840, 849-850 | l.price.parse::<Decimal>().ok()? + .size.parse::<Decimal>().ok()? per level | Malformed level → весь BookEvent в None | High — один корявый уровень убивает snapshot |
| 5 | book.rs:875 | Err(_) => return Vec::new() в parse_price_change_event | PriceChangeEvent parse-fail dropped | High |
| 6 | book.rs:881 | .timestamp.as_deref().and_then(|s| s.parse::<i64>().ok()) | Bad timestamp → None, теряем source ts | Medium — REST guard в ADR-022 degraded |
| 7 | book.rs:898, 902 | .best_bid / .best_ask parse → None | Bad NBBO field → drop | Medium |
| 8 | book.rs:908-916 | let Some(...) else { continue } для price/size/side в delta | Skip per-delta silently | High — один кривой delta пропадает |
| 9 | book.rs:920 | _ => continue для unknown side в match (BUY/SELL) | Unknown side → drop entry | Medium |
| 10 | book.rs:956 | sonic_rs::from_str(text).ok()? в parse_best_bid_ask_event | BBO parse-fail → None | Medium |
| 11 | book.rs:961, 965 | .best_bid/.best_ask.parse::<Decimal>().ok() | Bad decimal → None | Medium |
| 12 | book.rs:975-980 | let Ok(event) = ... else { return Vec::new() } + let Ok(price) = ... в parse_last_trade_price_event | LastTrade parse-fail → drop | Medium |
| 13 | book.rs:1009 | sonic_rs::from_str(text).ok()? в parse_new_market_event | NewMarket dropped | Critical — теряем admission event'ы → market не попадает в universe |
| 14 | book.rs:1082 | sonic_rs::from_str(text).ok()? в parse_market_resolved_event | MarketResolved dropped | Critical — terminal event missed → орудия на resolved book |
| 15 | book.rs:1110 | sonic_rs::from_str(text).ok()? в parse_tick_size_change_event | TickSizeChange dropped | High — Codex INV-3 нарушится |
Дополнительно в crates/pm-bot/src/io/ws/user.rs (649 LOC, user channel — out-of-band fills/cancels):
| # | Line | Pattern | Risk |
|---|---|---|---|
| 16 | user.rs:361 | sonic_rs::from_str(text).ok()? в parse_user_event | Trade/order event swallowed |
| 17 | user.rs:434, 440 | _ => {} в match по msg_type (внутри order branch и outer event_type) | Unknown order/event subtype drop |
2.2 Reconnect flow — отсутствие явного REST resync
Текущее поведение (scanner.rs:504-572 + book.rs:303-381):
connection_loop(book.rs:316-381) — supervisor: при ошибкеconnect_and_runинкрементитreconnect_counter, sleep backoff, повторно вызываетconnect_and_run.connect_and_run(book.rs:393) при успехе сбрасываетdisconnected_at = None, помечаетconnected = true, шлётbuild_subscribe_messages(tokens, Subscribe)сinitial_dump: true.- В scanner.rs:510-517 на edge
prev_ws_connected=false → ws_live=trueвызываетсяinvalidate_orderbook_cache(&tokens)— entries вOrderbookStoreпомечаются stale (timestamp → 1 ч назад) или удаляются. - После этого decision-paths видят
Wait { Stale }и FSM вызываетOrderManager::refresh_orderbook_restlazily — по одной таре на FSM hop. Нет batched REST burst. - Reconnect reason не классифицирован: текущий код различает только три clean-close строки (
shutdown/receiver_dropped/server_close/stream_ended) vsErr. PING timeout, half-open, malformed message — все попадают вErr-bucket без типизации. WsReconnectedevent наверх не эмитится — scanner лишь наблюдает edgeconnectedboolean.
После BUG-11 merge: ReconciliationPool на скорости rate-budget (1200 max / 100 reserve per 10s) поллит REST для всех subscribed → recovery происходит, но за окно до full sweep'а strategies могут принимать решения по stale levels.
V1 закрывает gap синхронно в момент reconnect detection: scanner получает WsReconnected event с list of subscribed assets, запускает burst REST fetch (sequential, sub-second wall-clock на ~50-200 ассетов при rate-limit 1500 GET /book per 10s), применяет через OrderbookStore::update_from_rest_snapshot — ADR-022 REST guard сам разрулит race с свежими WS deltas.
2.3 ReconnectReason — текущие call sites
В connect_and_run reconnect триггерится из (внутри process_messages):
| Source | book.rs line | Type | Predicted ReconnectReason |
|---|---|---|---|
activity_deadline elapsed | 496-498 | Err | HalfOpen (90s ACTIVITY_TIMEOUT — нет inbound traffic) |
| read error | 601-602 | Err | ReadError(String) |
| stream None | 604-605 | Ok("stream_ended") | Disconnect |
| server Close frame | 590-592 | Ok("server_close") | Disconnect |
| receiver dropped | 585-586 | Ok("receiver_dropped") | ShutdownLike (вышестоящий dropped Rx — не reconnect, exit path) |
connect_async failure (book.rs:393) | 393 | ? propagates | ConnectFailure(String) |
| unsubscribe/subscribe send fail | 524-525, 536-538 | Err | ResubscribeFailure(String) |
Ping send fail (ping_loop) | 467-470 | terminates ping, не reconnect напрямую | (косвенно вызовет HalfOpen через activity timeout) — PingTimeout mapping через сигнал из ping_loop в supervisor |
Текущий ping_loop не сигналит supervisor: он только пишет debug. Активный сигнал «pong missed» требует доп wiring если хотим явный PingTimeout reason. Решение: V1 классифицирует через ACTIVITY_TIMEOUT branch (90s), маркируя как HalfOpen (это и есть half-open detection в нашей модели — PING'и пишутся, activity timer не сбрасывается ⇒ TCP open но трафика нет). Отдельный PingTimeout (быстрее 10s) — defer на V1.1.
3. Архитектурное решение
3.1 Strict fail-fast parser policy
Политика: никакого silent drop. Каждая ветка делится на три класса:
- Class A — unknown event_type: невидимая новая шина. В prod →
tracing::error!+ws_unknown_event_total{event_type}counter + продолжить. В debug →panic!. Это обеспечивает immediate observability в проде и fail-fast в test/CI. - Class B — parse error (malformed JSON, missing required field, decimal parse fail): prod →
tracing::error!+ws_parse_errors_total{kind, event_type}counter + skip frame; debug →panic!. Skip frame (не панику) в prod выбран потому что Polymarket периодически шлёт legacy/edge payloads; ронять процесс на каждую кривость = self-DoS. - Class C — bounded enum mismatch (unknown
side, unknownstatus): prod →tracing::warn!+ws_unknown_enum_total{enum, value}counter + skip конкретный entry; debug →panic!. order.type(user-канал) — переклассифицирован в Class-C-подобный (user-ws-parser-conformance, 2026-09-02): незнакомое значение теперь даётOrderMsgType::Unknown(raw)вместо обрыва кадра (crates/pm-sdk/src/ws/user/parse.rs), тем же приёмом, что три соседних перечисления этого канала. Именованное расхождение, НЕ созданное этой правкой: фактическийdispatch_frame(crates/pm-sdk/src/ws/user/channel.rs) на путиOk(event)вообще не читаетstrict_modeни для одного из трёх уже существующихUnknown(...)-перечислений канала (TradeStatus,Outcome,TraderSide) — ни debug, ни release на них не паникуют, вопреки тому, что этот раздел утверждает про Class C выше.order.typeтеперь разделяет то же фактическое поведение, что и три его соседа; сам документ пока не приведён в соответствие с кодом — полная сверка вне объёма этой карточки.
Решение по policy mechanism: cfg(debug_assertions) — compile-time gate, не runtime config knob.
Обоснование:
- runtime flag создаёт риск misconfiguration в production (включишь panic-mode по неосторожности на live trading → процесс падает на первом кривом payload).
cfg(debug_assertions)автоматически включён вcargo test,cargo runбез--release,cargo check— все dev workflows. Автоматически выключен вcargo build --release(production VPS builds, memoryfeedback_release_build_for_vps).- нулевой runtime overhead в release.
- совпадает с existing convention в репо:
parser_panics_on_unknown_event_in_debug— естественное название теста, читаемое.
Альтернатива (env var WS_PARSER_STRICT=1) рассмотрена и отвергнута: добавляет runtime config surface без выгоды (никто не запускает release с panic-mode).
Шаблон для каждой ветки:
rust
// Class A — unknown event_type
other => {
let preview: String = text.chars().take(200).collect();
tracing::error!(event_type = other, preview = %preview, "ws_client: unknown event_type");
metrics::counter!("ws_unknown_event_total", "event_type" => other.to_string()).increment(1);
#[cfg(debug_assertions)]
panic!("unknown WS event_type in debug build: {} preview={}", other, preview);
#[cfg(not(debug_assertions))]
Vec::new() // observable already — continue
}
// Class B — parse error
Err(e) => {
tracing::error!(error = %e, len = text.len(), "ws_client: BookEvent JSON parse failed");
metrics::counter!("ws_parse_errors_total", "kind" => "json", "event_type" => "book").increment(1);
#[cfg(debug_assertions)]
panic!("BookEvent parse error in debug build: {} text={}", e, text);
#[cfg(not(debug_assertions))]
return Vec::new();
}3.2 Reconnect → REST resync flow
3.2.1 ReconnectReason enum (в book.rs)
rust
#[derive(Debug, Clone)]
pub enum ReconnectReason {
/// Server sent close frame or stream ended cleanly.
Disconnect,
/// Activity timeout (90s) — TCP open but no inbound traffic.
HalfOpen { silent_secs: u64 },
/// `connect_async` failed during connection establishment.
ConnectFailure(String),
/// Underlying WS `read` errored (transport / TLS / framing).
ReadError(String),
/// Subscribe / unsubscribe send failed mid-session.
ResubscribeFailure(String),
}
impl ReconnectReason {
pub fn as_metric_label(&self) -> &'static str {
match self {
Self::Disconnect => "disconnect",
Self::HalfOpen { .. } => "half_open",
Self::ConnectFailure(_) => "connect_failure",
Self::ReadError(_) => "read_error",
Self::ResubscribeFailure(_) => "resubscribe_failure",
}
}
}3.2.2 WsEvent::Reconnected variant
Добавить вариант в существующий enum WsEvent (book.rs:69):
rust
/// Emitted by the WS supervisor on every successful (re)connect after the
/// first one. The `subscribed_assets` snapshot is taken AT THE MOMENT of
/// resubscribe so the consumer (scanner) can drive a burst REST resync
/// over the exact set the wire is subscribed to.
Reconnected {
subscribed_assets: Vec<String>,
reason: ReconnectReason,
disconnect_duration_ms: u64,
}Эмит происходит в connect_and_run (book.rs:393-411) сразу после успешного subscribe burst и flip connected = true. Reason передаётся как параметр из connection_loop (наследуется от предыдущей итерации supervisor loop).
Изменение supervisor сигнатуры: connect_and_run начинает принимать prev_outcome: Option<&PrevAttemptOutcome> (либо приватный enum внутри supervisor), чтобы при успехе он знал reason для уже завершившейся reconnect-попытки. Альтернатива — supervisor сам шлёт WsEvent::Reconnected в tx после connect_and_run отметил connected=true. Выбор: supervisor сам шлёт, проще; нужен small refactor чтобы supervisor имел доступ к tx (он его уже принимает на вход).
3.2.3 Scanner handler
В scanner.rs main event loop (после строки 574 match ws_event { ... }):
rust
WsEvent::Reconnected { subscribed_assets, reason, disconnect_duration_ms } => {
tracing::warn!(
?reason,
count = subscribed_assets.len(),
disconnect_duration_ms,
"WS reconnected — triggering REST burst resync"
);
metrics::counter!("ws_reconnect_total", "reason" => reason.as_metric_label()).increment(1);
let start = Instant::now();
let mut ok = 0usize;
let mut failed = 0usize;
for asset_id in &subscribed_assets {
match order_manager.clob.fetch_orderbook_rest_with_metadata(asset_id).await {
Ok(snapshot) => {
let _ = order_manager.orderbook_store.update_from_rest_snapshot(snapshot);
ok += 1;
}
Err(e) => {
tracing::error!(error = %e, asset_id, "REST resync fetch failed");
failed += 1;
}
}
}
let elapsed_ms = start.elapsed().as_millis() as u64;
metrics::counter!("ws_resync_assets_total", "outcome" => "ok").increment(ok as u64);
metrics::counter!("ws_resync_assets_total", "outcome" => "failed").increment(failed as u64);
metrics::histogram!("ws_resync_duration_ms").record(elapsed_ms as f64);
tracing::info!(ok, failed, elapsed_ms, "WS resync burst completed");
}Sequential vs concurrent fetch: V1 — sequential. Обоснование:
- CLOB rate-limit на
GET /book= 1500 per 10s burst (memoryreference_polymarket_rate_limits). 200 sequential calls при p50 RTT 30ms ≈ 6s — укладывается в budget. - Sequential защищает от rate-limit bursting; concurrent (например
buffer_unordered(16)) выгоден только при >300 ассетов с высоким RTT — не наш типичный случай. - Concurrent опция (rt-budget-aware) — defer на V1.1 если профилирование покажет >5s elapsed на типичном reconnect.
Race с reconciliation pool: ReconciliationPool (BUG-11) уже поллит REST в background. V1 burst — это точечный sync trigger, может приземлиться одновременно с pool fetch'ем для тех же ассетов. Защита: ADR-022 REST guard (accept_rest_levels) reject'ит более старый источник timestamp'а; одинаковые (asset_id, source_timestamp_ms) идемпотентно дают одинаковый результат.
3.3 Метрики
| Metric | Type | Labels | Description |
|---|---|---|---|
ws_unknown_event_total | counter | event_type | Increment per unknown event_type на wire |
ws_parse_errors_total | counter | kind (json/decimal/missing_field), event_type (book/price_change/...) | Parse-failures по типу |
ws_unknown_enum_total | counter | enum (side/status/...), value | Out-of-spec enum values |
ws_reconnect_total | counter | reason | Per ReconnectReason; expected ~0/day в healthy ops |
ws_resync_assets_total | counter | outcome (ok/failed) | REST burst fetch results |
ws_resync_duration_ms | histogram | (none) | Wall-clock REST burst sweep duration |
Все 6 метрик регистрируются в том же месте, где уже регистрируются другие counters (order_manager.metrics поле — ws_reconnect_count уже там, scanner.rs:1504).
4. План реализации (5 phases)
Phase 1 — Strict parser audit (book.rs + user.rs)
Scope: 15 silent-skip веток в book.rs (lines from §2.1) + 2 в user.rs. Work:
- Inline transform каждой ветки по шаблонам Class A/B/C.
- Per-event_type label propagation в
ws_parse_errors_total. - Update
parse_ws_message_for_test— должна оставаться валидной для replay тестов. Tests (new incrates/pm-bot/tests/): ws_parser_strict_unknown_event_panics_in_debug.rs— feed unknownevent_type→ expect panic incfg(test)(debug_assertions on).ws_parser_strict_malformed_book_logs_and_skips.rs— invalid JSON → returns empty vec, counter incremented (need test-hook to read counter; or assert via tracing capture).ws_parser_strict_unknown_side_skips_entry.rs—side: "FROB"→ skip one delta, keep others. Footprint: ~80 LOC прод-кода, ~120 LOC тестов.
Phase 2 — ReconnectReason + WsEvent::Reconnected emit
Scope: book.rs supervisor refactor + WsEvent enum extension. Work:
- Add
ReconnectReasonenum (~30 LOC). - Add
WsEvent::Reconnectedvariant (~10 LOC). connection_looptracksprev_reason: Option<ReconnectReason>across iterations.connect_and_runпосле successful subscribe burst шлётWsEvent::Reconnectedв tx (только если prev_reason.is_some() — на самом первом connect не шлём).- Map call sites → reasons:
- book.rs:393 (
connect_async?) →ConnectFailure(e.to_string()) - book.rs:497 (activity_deadline) →
HalfOpen { silent_secs: 90 } - book.rs:602 (read error) →
ReadError(e.to_string()) - book.rs:592 (server Close) / book.rs:605 (stream ended) →
Disconnect - book.rs:524/536 (subscribe send fail) →
ResubscribeFailure(e.to_string()) - book.rs:586 (receiver_dropped) → не reconnect, supervisor выходит (cancel propagation). Tests:
- book.rs:393 (
ws_reconnected_event_emitted_with_reason_halfopen.rs— simulate activity_timeout via test harness; assert event emitted.ws_reconnected_event_subscribed_assets_matches_resubscribe.rs— set N tokens, force reconnect, assert event.subscribed_assets == N. Footprint: ~120 LOC прод-кода (включая supervisor refactor для prev_reason wiring), ~80 LOC тестов.
Phase 3 — Scanner REST resync handler
Scope: scanner.rs main event loop new arm. Work:
- Match arm для
WsEvent::Reconnected { subscribed_assets, reason, disconnect_duration_ms }сразу после/до существующегоWsEvent::Pricearm (scanner.rs:575). - Sequential
for asset_id in &subscribed_assets { fetch_orderbook_rest_with_metadata().await }. - Применять через
order_manager.orderbook_store.update_from_rest_snapshot(snapshot). - Emit 4 метрики per burst.
- НЕ удалять существующий
invalidate_orderbook_cache(scanner.rs:511) — он работает на edgeprev_ws_connected=false → trueчерез ws_client.connected bool и остаётся как defense-in-depth. Tests (integration, вcrates/pm-bot/tests/): ws_reconnect_triggers_rest_burst.rs— feedWsEvent::Reconnectedчерез test channel, mockOrderClientзаписывает все fetch вызовы → assert == subscribed_assets list.ws_reconnect_resync_handles_partial_failure.rs— mock роняет N/3 fetch'ей → assert ok counter + failed counter согласованы. Footprint: ~60 LOC прод-кода, ~120 LOC тестов.
Phase 4 — Metrics registration & dashboard wiring
Scope: метрики уже есть как metrics::counter!() calls — нужно убедиться что они инициализированы и видны на Local-Bot Mode panel. Work:
- Найти existing metrics init point (поиск по
metrics::describe_counter!/register_counter!). - Добавить describe для 6 новых.
- Опционально: surface в
BotSnapshot(pm-bot-events) если пользователь хочет видеть в дашборде. Defer до фидбэка — V1 ограничивается логами + Prometheus-scrape. Footprint: ~30 LOC.
Phase 5 — E2E verify + clippy + release
Scope: pre-merge gates. Work:
cargo test -p pm-bot --test 'ws_*'зелёный.cargo clippy -p pm-bot --all-targets -- -D warningsclean (без новых allow).cargo build -p pm-bot --releaseok (на dev workstation — для VPS пользователь сам через scripts/deploy-vps.sh).- Verify: на VPS после deploy, в логах
grep ws_reconnect_totalпоказывает ≥1 событие за 1ч (естественные disconnect'ы в проде). - VERIFIED markers расставить на:
connection_loop(book.rs:303),parse_ws_message_impl(book.rs:774), scanner.rs handler. Footprint: docs + verification, нет нового кода.
5. Coverage rule-IDs (strategy.md §9)
Полностью покрывает V1
| Rule-ID | Покрытие | Комментарий |
|---|---|---|
R-WS-RESYNC-AFTER-RECONNECT (§9.1 line 529) | ✓ | Phase 3 — burst REST fetch для всех subscribed |
R-WS-RESNAPSHOT-AFTER-RECONNECT (§9.2 line 548) | ✓ | то же, через update_from_rest_snapshot |
R-WS-PARSE-ERROR-RESTART-POLICY (§9.2 line 542) | ✓ | Phase 1 — debug panic / prod log+counter, не silent |
R-WS-UNKNOWN-MSG-RAW-OR-DEADLETTER (§9.2 line 543) | ✓ | Phase 1 — error log с raw preview + counter (раw → log как dead-letter analog) |
R-WS-NO-SILENT-MALFORMED-SKIP (§9.3 line 565) | ✓ | Phase 1 — устраняет все 15 silent-skip веток |
R-WS-NO-EVENTS-AFTER-RECONNECT-WITHOUT-RESYNC (§9.3 line 562) | ✓ | Phase 3 — burst REST до того как scanner начнёт принимать решения по post-reconnect WS deltas (synchronous handler) |
R-WS-RECONNECT-CASE-DISCONNECT (§9.4 line 575) | ✓ | ReconnectReason::Disconnect |
R-WS-RECONNECT-CASE-HALF-OPEN (§9.4 line 576) | ✓ | ReconnectReason::HalfOpen via activity_timeout |
R-WS-RECONNECT-CASE-PING-TIMEOUT (§9.4 line 577) | ✓ (через HalfOpen) | PingTimeout distinct labeling — defer V1.1; pragma — half-open == «pings flow но трафика нет», что наш текущий activity timer и измеряет |
R-WS-RECONNECT-CASE-MALFORMED (§9.4 line 578) | ✓ | Phase 1 ловит malformed на парсере; не reconnect, но evidence chain закрыт |
R-WS-RECONNECT-EVIDENCE-LOGGED (§9.4 line 590) | ✓ | tracing::warn! per reconnect |
R-WS-RECONNECT-EVIDENCE-METRIC (§9.4 line 591) | ✓ | ws_reconnect_total{reason} |
R-WS-RECONNECT-EVIDENCE-POST-RESNAPSHOT (§9.4 line 593) | ✓ | Burst REST fetch + apply |
Частично покрывает (ослабленная форма)
| Rule-ID | Status | Что не сделано |
|---|---|---|
R-WS-RECONNECT-EVIDENCE-DEGRADED-STATE (§9.4 line 592) | partial | ws_client.connected: AtomicBool уже есть; «recovering» state distinct from «degraded» — не введён. Defer. |
R-WS-GAP-DETECT (§9.1 line 527) | partial | Phase 1 fail-fast детектит parser drift; gap-by-seq-number — нет (см. exclusions) |
Explicitly OUT OF SCOPE
| Rule-ID | Причина |
|---|---|
R-WS-GAP-MARKER-DB (§9.2 line 549) | DB schema work — отдельный backlog, нужна таблица ws_gap_markers для backtest replay exclusion |
R-WS-STREAM-SESSION-ID (§9.2 line 550) | Polymarket не присылает session_id на wire (memory reference_ws_troubleshooting) — sticky-routing невозможен |
R-WS-MONOTONIC-INGEST-SEQ (§9.2 line 551) | Отвергнут в project_ws_self_healing_decision (Вариант 2 reject) — steady-rate drift не детектится |
R-WS-DEDUP-KEY (§9.2 line 555) | Hash-content dedup отсутствует; REST resync polagается на ADR-022 REST guard; полноценный dedup — отдельный fix |
R-WS-RAW-PAYLOAD-HASH (§9.2 line 554) | Capture/replay path (ADR-009) уже пишет raw; in-memory hashing — defer |
R-WS-RECONNECT-CASE-NET-PAUSE (§9.4 line 579) | Effectively covered by ACTIVITY_TIMEOUT (90s) → HalfOpen, но distinct net-pause labeling defer |
R-WS-RECONNECT-CASE-DB-UNAVAIL (§9.4 line 580) | Не WS-layer concern; orthogonal к этому fix |
R-WS-RECONNECT-CASE-BURST (§9.4 line 581) | Initial dump batching уже работает (SUBSCRIBE_BATCH_SIZE=500); burst-after-reconnect не вызывает crash — current code handles |
R-WS-RECONNECT-CASE-SUB-REJECTED (§9.4 line 582) | Subscription rejection wire-protocol Polymarket'а не публичен — defer до captured rejection sample |
R-WS-RECONNECT-CASE-PARTIAL-SUB (§9.4 line 583) | Same — нет evidence какие frames Polymarket шлёт при partial reject |
R-WS-RECONNECT-CASE-TOKEN-REMOVED (§9.4 line 584) | Покрывается WsEvent::MarketResolved handling (другой кодовый путь, не WS reconnect) |
R-WS-RECONNECT-EVIDENCE-GAP-MARKER (§9.4 line 594) | Связан с R-WS-GAP-MARKER-DB — defer вместе |
R-WS-RECONNECT-EVIDENCE-BACKTEST-EXCLUDABLE (§9.4 line 595) | Same |
Итого V1: 13 полных + 2 частичных. После V1 в §9 остаются 13 open rule-IDs для будущих fix'ов (gap-marker DB, dedup, monotonic seq — каждый отдельный design).
6. Footprint
| Layer | Estimate |
|---|---|
crates/pm-bot/src/io/ws/book.rs (parser strict) | ~80 LOC delta |
crates/pm-bot/src/io/ws/user.rs (parser strict) | ~25 LOC delta |
crates/pm-bot/src/io/ws/book.rs (ReconnectReason + WsEvent::Reconnected emit) | ~120 LOC delta |
crates/pm-bot/src/application/scanner.rs (resync handler) | ~60 LOC delta |
| Metrics descriptor wiring | ~30 LOC |
| Tests (unit + integration) | ~320 LOC (5 unit + 2 integration) |
| Docs (this design + components-status update) | already covered |
| Total prod code | ~315 LOC across 3 files |
| Total test code | ~320 LOC in 5-7 new test files |
7. Acceptance criteria
- AC-1 Все 17 silent-skip locations устранены — verified re-grep по паттернам
_ => (),.ok()?,Err(_)вcrates/pm-bot/src/io/ws/{book,user}.rs(за исключением intentional Class Bcfg(not(debug_assertions))skip). - AC-2
WsEvent::Reconnectedemit'ится в 5 reconnect path'ах (Disconnect / HalfOpen / ConnectFailure / ReadError / ResubscribeFailure), unit-test per reason. - AC-3 Scanner делает sequential REST fetch для всех
subscribed_assetsпослеWsEvent::Reconnected, integration-test покрывает (a) full success path (b) partial failure path. - AC-4 6 метрик зарегистрированы и наблюдаемы (Prometheus scrape или structured log).
- AC-5
cargo clippy -p pm-bot --all-targets -- -D warningsзелёный. - AC-6
cargo build -p pm-bot --releaseзелёный. - AC-7 VERIFIED маркеры расставлены на
book.rs::connection_loop,book.rs::parse_ws_message_impl,scanner.rs::<resync handler>. - AC-8
docs/tests/components-status.md§9 обновлён: 13 полных rule-IDs → проверено; 2 partial → отмечены; 13 explicit OUT-OF-SCOPE → ссылка на этот design. - AC-9 Backlog #143 → DONE-143.
8. References
- Backlog:
docs/tests/backlog/DONE-143-ws-strict-fail-fast-resync.md - BUG-11 sibling fix (V3): commit
45295cf(merge) +96b0ce9(closure) ADR-022(REST guard for resync — unified-orderbook-merge)- Strategy.md §9 (WS Market channel):
docs/tests/strategy.mdlines 515-596 - Production WS layer:
crates/pm-bot/src/io/ws/book.rs(1868 LOC) — supervisor + parsercrates/pm-bot/src/io/ws/user.rs(649 LOC) — user channel parsercrates/pm-bot/src/application/scanner.rs:485-573— current edge-trigger Gap-B handler
- Memory anchors:
project_ws_self_healing_decision— V1+V3 architecture ratificationfeedback_ws_must_be_self_healing— fail-fast principlereference_ws_troubleshooting— Polymarket WS wire invariants