Эпик 4 sync-arch: правильная обработка форков блокчейна (релиз 1.1.2) [C28-14] #54
Reference in New Issue
Block a user
Delete Branch "parser2-epic-4-forks"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Что в эпике (C28-14)
Эпик 4 sync-arch — правильная обработка форков блокчейна через unified-stream fork-события. Релиз 1.1.2, базовая ветка
parser2.Истории
IForkAwareSyncerчерез DiscoveryService, последовательноеfor-of awaitприменение форка.@OnEvent('fork::*')и временный pause-barrier; форк теперь обрабатывается строго инлайн в unified-стриме.block_num: bigintушёл, осталсяnumber; grep-guard покрывает все sync-репозитории.Что НЕ вошло
Test plan
🤖 Generated with Claude Code
Цель — снять параллельный @OnEvent('fork::*') broadcast в 20+ syncer-ов и заменить на sequential обход через ForkRegistryService, что в сочетании с single-active XREADGROUP parser2 даёт натуральный барьер форка (INV-T03, NFR10). Старый broadcast путь оставлен deprecated на этот релиз — Story 4.2 удалит вместе с pause-barrier из DEC-013. Реализация: - shared/sync/fork/{interface,service,module}: IForkAwareSyncer + symbol-marker FORK_AWARE_MARKER, ForkRegistryService с pull-сбором через DiscoveryService на onApplicationBootstrap, sequential for-of await + priority-ordering. - AbstractEntitySyncService: implements IForkAwareSyncer (marker через class field), handleFork теперь re-throw (catch удалён) — контракт sequential ForkRegistry.runAll. Не требует super.onModuleInit() у 20 наследников (DiscoveryService снимает push-зависимость). - BlockchainConsumerService.handleEvent для fork: dedup-gate через computeForkEventId → processFork (runAll → deleteDedupAfterBlock → saveFork → deprecated emit) → markEventApplied. Для action/delta — markEventApplied теперь пишет block_num. - ConsumerDedup: новая колонка block_num (bigint nullable) + индекс + deleteAfterBlock(blockNum). Старые NULL-записи не затрагиваются. - event-id.util: новый computeForkEventId в формате chain:fork:block:short_id (полное «fork», единый стиль с action/delta — parser2-формат chain:f:... не используется). Тесты (51 кейс, 5 suites): - unit fork-registry: sequential apply, error propagation, priority, bootstrap discovery (15 кейсов). - unit blockchain-consumer.processFork: порядок шагов, dedup-gate, mark после processFork, fail-paths, parser2-format isolation (11 кейсов). - unit consumer-dedup repository: markApplied с/без blockNum, deleteAfterBlock edge-cases (8 кейсов). - unit event-id.util: computeForkEventId детерминизм + поведение на коротком block_id (5 новых кейсов). - integration fork-flow: bootstrap → delta(N+1)→fork(N)→delta(N+2), rollback не трогает <=N, error propagation (4 кейса с реальным Nest DI). tsc 0 ошибок, jest 51/51 зелёные. Spec: blago/production/13-platforma-tsifrovogo-kooperativa/components/14-versiya-3/_bmad-output/tracks/sync-arch/implementation-artifacts/spec-4-1-fork-kak-event-v-unified-stream.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>После Story 4.1 ForkRegistry уже даёт sequential rollback всех syncer'ов через pull-DiscoveryService, а saveFork идёт прямым вызовом из processFork. Старый broadcast-путь через EventEmitter ('fork::*' + emitAsyncWithTimeout с TTL force-resume из Story 1.3 DEC-013) — рудимент: дублирует saveFork (через BlockchainEventHandlerService.handleForkEvent), повторно зовёт handleFork в каждом syncer'е (idempotent no-op после ForkRegistry, но лишний DB-traffic). Удалено: - @OnEvent('fork::*') + handle*Fork методы в 20 файлах: · 3 в infrastructure/database/typeorm/blockchain/services/ (user-agreement, user-wallet, agreement) · 16 в extensions/capital/application/syncers/ · 1 в extensions/chairman/infrastructure/blockchain/services/ (approval) · 1 дубль saveFork в domain/parser/services/blockchain-event-handler.service.ts · orphan dead-метод в application/wallet/services/program-wallet-sync.service.ts - Шаг 4 (deprecated emitAsyncWithTimeout) из BlockchainConsumerService.processFork. - BLOCKCHAIN_FORK_PAUSE_TIMEOUT_MS из config/config.ts (zod + config объект). - Упоминание env-var из controller/CLAUDE.md. Что осталось: - EventsService.emitAsyncWithTimeout — определение в infrastructure/events/events.service.ts. Каллеров в src/ нет; не удаляю утилиту — возможно пригодится для Epic 5 (waitForDelta / pool retry). Удалят на следующем рефакторе если не использована. - AbstractEntitySyncService.handleFork остаётся: его теперь зовёт только ForkRegistry. - @OnEvent('action::*') и @OnEvent('delta::*') в BlockchainEventHandlerService — валидный dispatch pipeline (ADR-002), не трогаем. Tests: - processFork.test.ts: убраны expect'ы на emit; новый regression test что events.emit/emitAsyncWithTimeout НЕ вызываются с fork:: префиксом. - НОВЫЙ tests/unit/blockchain/no-onevent-fork.test.ts: grep-guard по src/ на @OnEvent('fork::*') (комментарии отфильтрованы) → fail на регрессии. - Все 6 suites зелёные (52 теста), tsc --noEmit 0 errors. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Audit-проход без миграции рантайма. Полная карта entity/repo и сравнений block_num — в blago _bmad-output/tracks/sync-arch/.../audit-report-4-3.md. Изменения в коде: 1. tests/unit/blockchain/base-blockchain-repository.contract.test.ts — НОВЫЙ regression-guard. Сканирует все *.typeorm-entity.ts extends BaseTypeormEntity и для каждой ищет *.typeorm-repository.ts extends BaseBlockchainRepository. Allowlist OFF_CHAIN_BASE_ENTITIES для 5 off-chain артефактов (comment, cycle, issue, story, time-entry) — у них block_num унаследован vestigial, синкера нет, форк их не откатывает (корректно). Тест ловит регрессию, если кто-то добавит блокчейн-mirror entity без BaseBlockchainRepository — иначе entity_versions молча перестанут писаться, форк превратится в hard delete (silent data loss). 2. controller/CLAUDE.md — два новых правила: (a) разнобой типов block_num: BaseTypeormEntity (capital+shared) использует integer+number — корректно; ActionEntity/DeltaEntity/ForkEntity/ SyncStateEntity — bigint+number type-mismatch (PG возвращает string, TS говорит number). Hot-path везде явно Number() либо PG bind, так что runtime safe. Технический долг в Epic 9 — bigint Transformer. (b) контракт «entity с block_num → repo extends BaseBlockchainRepository» + ссылка на regression test + allowlist OFF_CHAIN_BASE_ENTITIES. 3. tests/unit/blockchain/base-blockchain-repository.contract.test.ts: нюанс — entityKindFromFileName нормализует суффикс -typeorm (chairman approval-typeorm.entity.ts vs approval.typeorm-repository.ts mismatch имён файлов). Findings (полное в audit-report-4-3.md): - 25 entity extends BaseTypeormEntity; 20 имеют BaseBlockchainRepository. - 5 без BaseBlockchainRepository — off-chain артефакты (легитимно). - 4 infra entity с bigint+number type-mismatch (runtime safe, тех. долг). - Direct typeormRepo.save для блокчейн-зеркал — НЕ найдено (контракт соблюдён). - Все сравнения block_num в sync-core используют Number() или PG bind. Tests: 2/2 contract-test зелёные. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>AbstractBlockchainDeltaMapper получает protected readonly signedDocumentFields ReadonlyArray<SignedDocField> (default []) + helper normalizeSignedDocuments(data). SignedDocField описывает путь к IChainDocument2-полю в TBlockchainData; bracket- нотация поддерживает массивы: `appendix` (top-level), `statement.attachments[] .signed_attachment` (nested-array, E12). parseSignedDocPath → PathSegment[], applyAtPath in-place трансформирует через DomainToBlockchainUtils.convertChainDocumentToDomainFormat. appendix-delta.mapper.ts — эталон: ручной convertChainDocumentToDomainFormat (value.appendix) заменён декларацией signedDocumentFields = [{ path: 'appendix' }] + this.normalizeSignedDocuments({ ...value }). signed-document-normalization.test.ts (16) — парсер 4 кейса + normalize 7 кейсов (top-level, nested-array, missing-field no-op, multiple fields, empty config, null/undefined, immutability caveat). delta-mapper-signed-doc.contract.test.ts (3) — guard: mapper с непустым signedDocumentFields обязан вызвать this.normalizeSignedDocuments — иначе silent data corruption (meta остаётся JSON-строкой, Story 6.4 checksum не сходится). IPFS lazy resolver (E14) и миграция остальных mapper'ов с ручной нормализацией — вне scope, тречится Epic 9. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Новые shared/sync/errors: - UnsupportedContractVersionError(entityName, ctx{contract,table,primary_key, block_num}) — носит контекст для DLQ-операторской диагностики. - auditUnknownStatus(entityName, receivedStatus, logger, allowedStatuses?) — фиксирует unknown-status drift в audit-trail с ожидаемыми вариантами. AbstractEntitySyncService.processDelta: mapper вернул null → logger.error ("UNSUPPORTED_CONTRACT_VERSION", ctx) ВСЕГДА (раньше был silent warn). В strict-mode (config.blockchain.unsupported_version_strict=true) дополнительно throw — парсер не ACK'нет дельту, DLQ сработает. UnsupportedContractVersionError из try/catch пробрасывается дальше; остальные ошибки по-прежнему логируются и return null. config.ts: BLOCKCHAIN_UNSUPPORTED_VERSION_STRICT (default false) → blockchain .unsupported_version_strict. Default false — не ломать прод немедленно; включается на стенде после подтверждения отсутствия schema drift. ProjectDomainEntity.mapStatusToDomain эталонно — default ветка вместо silent UNDEFINED-возврата вызывает auditUnknownStatus с полным списком ожидаемых: [pending,active,voting,result,finalized,cancelled]. Остальные mapStatusToDomain (state, vote, segment, debt и т.д.) — Epic 9.5. unsupported-version-explicit-error.test.ts (6) — UnsupportedContractVersionError конструктор/контекст; auditUnknownStatus с/без allowedStatuses; processDelta non-strict (logger.error + null), strict (throw), happy-path (handleSyncDelta вызывается). 229/229 blockchain unit зелёные. Эпик 6 закрыт целиком: 6.1 namespace, 6.2 normalizeSignedDocuments, 6.3 Zod schemas, 6.4 checksum, 6.5 explicit errors. Release 1.1.2 движется. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Story 6.3 (per-doc-type zod schemas): валидация `signatures.length === 1/2` зашивала документную семантику в нормализатор parser-event. Число и валидность подписей — домен контракта (`require_auth`), не бэкенда. При расширении доктайпов контракта (1→2 подписи) sync падал бы на структурно-валидной цепи. Структурный transform `meta: JSON-string → object` (Story 6.2) остаётся. Story 6.4 (`_checksum` колонка + sha256(canonical-json(bc))): потребитель — Epic 7 nightly snapshot и Epic 8 reconciliation — ещё не реализованы. Колонка хранила бы пустую нагрузку + +20% storage + лишний sha256 на каждом save/update. Принцип «не добавлять контрольных полей до появления потребителя». Алгоритм canonicalStringify заведём как часть Epic 8. Удалено: src/shared/sync/{checksum.util.ts, signed-document-schemas.ts} + 3 теста + applyBcChecksum + колонка _checksum + поле _checksum на BaseDomainEntity. Story 6.1 (namespace db/bc + replaceBc), 6.2 (declarative signedDocumentFields + meta transform), 6.5 (UnsupportedContractVersionError + auditUnknownStatus) сохраняются. 13 jest blockchain unit suites / 196 tests зелёные. tsc зелёный. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>