feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,640 @@
|
||||
# VD-28: Realtime events, Web Push와 bounded polling
|
||||
|
||||
- 상태: Accepted — reference runtime available, product implementation pending
|
||||
- 결정일: 2026-07-28
|
||||
- 관련 결정: VD-10, VD-13, VD-23, VD-24, VD-25, VD-26, VD-27, VD-29
|
||||
- 상세 설계:
|
||||
`docs/architecture/realtime-events-web-push-and-bounded-polling.md`
|
||||
- 현재 product selection: `NOT_SELECTED`
|
||||
- common runtime delta: `AVAILABLE_NOT_COMPOSED`
|
||||
- 재검토:
|
||||
첫 제품 stream/Web Push를 선택할 때, 또는 backend replay/hosting/provider
|
||||
protocol이 바뀔 때
|
||||
|
||||
## 배경
|
||||
|
||||
현재 optional recipe catalog는 realtime capability에
|
||||
`referenceRuntime.status=AVAILABLE_NOT_COMPOSED`를 기록한다. 공통 event authority,
|
||||
bounded reconnect owner, single-writer live↔Poll handoff, fetch-stream SSE,
|
||||
bounded Polling, closed WebSocket protocol과 Web Push window/worker adapter는
|
||||
deterministic test와 함께 존재하지만 production entry에서는 제외된다. generic
|
||||
mega `RealtimePort`, 제품 event schema, 실제 endpoint, backend replay/provider
|
||||
contract와 composition은 선택하지 않았다.
|
||||
|
||||
추가 설계 범위에는 성격이 다른 네 capability가 있다.
|
||||
|
||||
- SSE: active document의 server-to-client event stream
|
||||
- WebSocket: active document의 duplex application protocol
|
||||
- Web Push: inactive browser에도 도착할 수 있는 Service Worker 기반 notification
|
||||
- bounded polling: 기존 HTTP/query operation의 제한된 scheduling policy
|
||||
|
||||
이를 “realtime transport” 하나로 합치면 다음 문제가 생긴다.
|
||||
|
||||
- Web Push의 permission, push service와 worker lifecycle이 connection 상태에 숨는다.
|
||||
- Polling을 무한 timer나 transport downgrade로 오해한다.
|
||||
- WebSocket이 필요하지 않은 server notification까지 duplex protocol이 된다.
|
||||
- connection open, event delivery, application effect와 server 최신성을 같은 성공으로
|
||||
표시한다.
|
||||
- auth refresh, reconnect, HTTP retry와 Query retry가 중첩된다.
|
||||
- gap, cursor expiry와 browser restore 뒤 authoritative resync owner가 사라진다.
|
||||
- push subscription endpoint/key나 cursor가 일반 application state와 telemetry에
|
||||
노출될 수 있다.
|
||||
|
||||
기존 recipe의 generic `channel: string`, `sequence: number`, 고정
|
||||
`resumeToken`, callback과 `heartbeat()`는 선택 시 복사해 좁힐 출발점이다.
|
||||
scope/epoch, closed event type, byte/queue limit, gap/reset, 진행되는 cursor,
|
||||
generation과 effect certainty가 없어 production wire authority로 사용할 수 없다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| 항목 | 상태 | 설명 |
|
||||
| --- | --- | --- |
|
||||
| optional realtime catalog/recipe | `RECIPE_AVAILABLE` / product `NOT_SELECTED` | uncomposed reference runtime과 conformance script가 있음 |
|
||||
| common event/recovery/reconnect runtime | `AVAILABLE_NOT_COMPOSED` | scope/gap/barrier authority, finite reconnect owner와 exact close classification test가 있음 |
|
||||
| live↔Poll handoff coordinator | `AVAILABLE_NOT_COMPOSED` | monotonic generation, one effect writer와 bounded checkpoint/quiescence test가 있음 |
|
||||
| SSE runtime | `AVAILABLE_NOT_COMPOSED` | fetch-stream parser/adapter/reconnect test 있음; local server/browser evidence pending |
|
||||
| WebSocket runtime | `AVAILABLE_NOT_COMPOSED` | exact handshake/protocol/queue/recovery test 있음; load/browser evidence pending |
|
||||
| bounded polling coordinator | `AVAILABLE_NOT_COMPOSED` | finite single-flight visible/online lease와 deterministic budget test 있음 |
|
||||
| Web Push window/worker runtime | `AVAILABLE_NOT_COMPOSED` | subscription, registration/revoke, durable fence, strict inbound worker factory가 있음; provider/browser evidence pending |
|
||||
| exactly-once/global ordering | `PLATFORM_LIMITED` | 공통 browser delivery 목표로 보장하지 않음 |
|
||||
| always-on background connection/polling | `PLATFORM_LIMITED` | hidden/frozen/terminated document에서 보장하지 않음 |
|
||||
| timely cross-browser Web Push | `PLATFORM_LIMITED` | provider/browser/OS가 즉시 delivery를 보장하지 않음 |
|
||||
|
||||
reference source는 `AVAILABLE_NOT_COMPOSED`까지 승격됐다. 그러나 이 ADR과
|
||||
deterministic test만으로 `COMPOSED` 또는 `PRODUCTION_READY`로 올리지 않는다.
|
||||
제품 endpoint/registry와 backend/provider/target-browser evidence가 생긴 뒤
|
||||
선택 capability만 별도 승격한다.
|
||||
|
||||
## 결정
|
||||
|
||||
### 1. 네 capability를 분리한다
|
||||
|
||||
다음 의미를 고정한다.
|
||||
|
||||
| capability | 선택 의미 | 기본 fallback |
|
||||
| --- | --- | --- |
|
||||
| SSE | foreground one-way ordered hint stream | bounded polling 또는 stale UI |
|
||||
| WebSocket | foreground duplex interaction protocol | 의미가 축소되지 않으면 bounded polling, 아니면 disabled/stale UI |
|
||||
| Web Push | background user-visible notification hint | foreground inbox/focus refresh |
|
||||
| bounded polling | finite visible HTTP scheduling | manual refresh/explicit stale UI |
|
||||
|
||||
Web Push는 SSE/WebSocket의 fallback이 아니라 보완 capability다. Polling은
|
||||
WebSocket duplex 기능을 대신할 수 없다. SSE↔WebSocket 자동 downgrade도 하지
|
||||
않는다. 같은 사용자 의미를 보존하는 fallback만 registry에 명시한다.
|
||||
|
||||
추가 transport를 선택하기 전 기존 TanStack Query의 focus/reconnect refetch와
|
||||
manual refresh가 측정된 freshness 요구를 만족하는지 먼저 확인한다.
|
||||
|
||||
Connect/gRPC-Web server stream은 VD-29/VD-27의 operation-bound API protocol이고 GraphQL
|
||||
subscription은 현재 `NOT_SELECTED`다. GraphQL `@defer`/`@stream`은 finite
|
||||
incremental HTTP response이지 realtime subscription이 아니다. RPC adapter가
|
||||
protocol-specific terminal proof와 protobuf decode/schema/mapper를 끝낸
|
||||
runtime-wide notification branch에서만 공통
|
||||
scope/gap/resync coordinator를 재사용한다. frame/media/trailer, reconnect와
|
||||
operation deadline owner를 SSE/WebSocket adapter로 합치거나 protobuf message를
|
||||
`REALTIME_EVENT_V1` JSON으로 다시 감싸지 않는다. Polling의 개별 attempt는 VD-23의
|
||||
terminal·replay-safe REST `QUERY` execution contract를 재사용하되 transport/Query
|
||||
retry는 끄고, 이 결정은 attempt 사이 bounded lease만 소유한다.
|
||||
|
||||
### 2. source of truth는 서버다
|
||||
|
||||
SSE/WebSocket event의 기본 효과는 registered `QueryInvalidationTopic`과 authoritative
|
||||
HTTP refetch다. raw event payload를 domain entity나 Query cache의 authoritative
|
||||
state로 자동 승격하지 않는다.
|
||||
|
||||
authoritative delta 적용은 event type별 server revision, base revision, commit
|
||||
뒤 publication, idempotent reducer, gap/reset과 snapshot reconciliation이 모두
|
||||
승인된 경우에만 별도 선택한다.
|
||||
|
||||
Web Push payload는 작은 opaque notification hint다. Poll response는 해당 HTTP
|
||||
representation의 결과다. 어느 것도 authorization이나 exactly-once effect를
|
||||
증명하지 않는다.
|
||||
|
||||
### 3. outbound connection과 inbound event adapter를 분리한다
|
||||
|
||||
outbound가 소유한다.
|
||||
|
||||
- fixed endpoint와 credential 협력
|
||||
- connect/subscribe/resume/reconnect/close
|
||||
- selected WebSocket typed send
|
||||
- push subscription register/revoke
|
||||
- bounded poll scheduling/cancel
|
||||
|
||||
inbound가 소유한다.
|
||||
|
||||
- raw byte/frame hard cap
|
||||
- UTF-8/JSON/schema/version 검증
|
||||
- stream/event/scope/generation 확인
|
||||
- dedupe/order/gap
|
||||
- feature input 또는 query invalidation mapping
|
||||
- effect 뒤 cursor/ack commit
|
||||
|
||||
application/domain에 native browser, TanStack, URL/header나 vendor type을 노출하지
|
||||
않는다. `send(unknown)`과 arbitrary `channel`/endpoint도 금지한다.
|
||||
|
||||
### 4. target event protocol을 versioning한다
|
||||
|
||||
foreground common envelope은 다음 의미를 가져야 한다.
|
||||
|
||||
```text
|
||||
protocol = REALTIME_EVENT_V1
|
||||
streamId = registry-owned ID
|
||||
streamEpoch = opaque server reset epoch
|
||||
eventType = closed registry ID
|
||||
eventId = bounded dedupe ID
|
||||
sequence = canonical unsigned decimal string
|
||||
recoveryMode = CURSOR | SNAPSHOT_ONLY | SESSION_REBUILD
|
||||
resumeCursor = CURSOR면 opaque replay position, 아니면 exact null
|
||||
occurredAt = strict RFC 3339, ordering authority 아님
|
||||
scopeBinding = session/BFF-issued opaque exact-match token
|
||||
payload = event-type-specific closed codec
|
||||
```
|
||||
|
||||
`eventId`, `sequence`, `resumeCursor`와 business revision은 별도 의미다.
|
||||
sequence는 JSON safe-integer 문제를 피하도록 decimal string으로 전달하고
|
||||
stream + epoch 안에서만 비교한다.
|
||||
|
||||
credential, readable subject/account ID, signed URL, PushSubscription material과
|
||||
자유 형식 message는 envelope에 넣지 않는다.
|
||||
event type registry는 payload schema, pure boundary mapper와 effect profile을
|
||||
함께 bind한다. `scopeBinding`은 cache fingerprint/authorization proof가 아니고,
|
||||
cursor는 protocol/stream/feed/epoch/registered subscription set/auth scope에
|
||||
server-side로 bind한다. client는 opaque cursor를 해석하지 않는다.
|
||||
state-bearing stream의 recovery profile은 snapshot operation/checkpoint codec과
|
||||
replay/connect-buffer/server-hold barrier를 닫는다. `SESSION_REBUILD`는
|
||||
EPHEMERAL-only다. V1 server-side subset filter는 `NOT_SELECTED`이며 필요하면
|
||||
contiguous sequence/checkpoint를 가진 별도 stream으로 등록한다.
|
||||
|
||||
### 5. delivery guarantee와 authoritative resync를 분리한다
|
||||
|
||||
apply 순서는 다음과 같다.
|
||||
|
||||
```text
|
||||
byte cap
|
||||
-> parse/schema/version
|
||||
-> registry/scope/generation
|
||||
-> dedupe/order/gap
|
||||
-> registered boundary mapper
|
||||
-> sequential application effect
|
||||
-> effect commit
|
||||
-> last-applied cursor
|
||||
-> optional selected WebSocket protocol ACK
|
||||
```
|
||||
|
||||
effect 뒤 cursor를 commit하므로 crash window에서 duplicate가 생길 수 있다.
|
||||
effect는 idempotent하거나 query invalidation/refetch여야 한다.
|
||||
|
||||
- 전체 browser lifecycle에 대한 delivery guarantee는 없음
|
||||
- retention 안의 `CURSOR` foreground event 처리만 duplicate-tolerant
|
||||
at-least-once model
|
||||
- V1 ordering은 stream-wide 하나; partition은 별도 logical stream
|
||||
- exact duplicate/old sequence는 safe drop
|
||||
- 같은 event ID/sequence의 conflicting content는 protocol failure
|
||||
- old captured generation callback만 safe drop; current connection의
|
||||
`scopeBinding` mismatch는 security protocol violation으로 close/revalidate/resync
|
||||
- sequence gap, stream epoch change, cursor expiry, queue overflow는 delta 적용 중단
|
||||
- authoritative snapshot과
|
||||
`SnapshotCheckpoint(streamEpoch,lastAppliedSequence,resumeCursor|null,snapshotRevision)`를
|
||||
같은 commit point로 얻은 뒤에만 resume
|
||||
- exactly-once와 global ordering은 비목표
|
||||
|
||||
backend는 commit 이후 publication, replay retention, cursor reset과
|
||||
snapshot/checkpoint 의미를 소유한다. subscribe ACK는 accepted cursor와
|
||||
`nextExpectedSequence`를 반환한다. replay가 없는 `SNAPSHOT_ONLY`는
|
||||
connect/bounded-buffer 또는 server hold barrier 없이는 snapshot/connect 사이
|
||||
event를 잃을 수 있으므로 `CURRENT`를 보장하지 않고 finite revalidation/stale UX로
|
||||
degrade한다.
|
||||
|
||||
### 6. lifecycle은 scope generation으로 fence한다
|
||||
|
||||
connection, freshness, authorization, availability와 traffic admission을 별도
|
||||
상태 축으로 둔다. `connected: boolean` 하나로 표현하지 않는다.
|
||||
|
||||
- runtime config/release/session recovery 뒤에만 connect한다.
|
||||
- route lease는 unmount에서 release한다.
|
||||
- logout/account/release transition은 old generation을 먼저 fence한다.
|
||||
- connect/read/backoff/poll/snapshot을 abort하고 queue/cursor/dedupe를 폐기한다.
|
||||
- late event/response/worker handoff는 captured old generation이면 적용하지 않는다.
|
||||
- close/dispose/unsubscribe는 terminal/idempotent다.
|
||||
- React StrictMode 반복 뒤 physical listener/connection/timer가 하나만 남는다.
|
||||
- admission은 canonical `DISABLED | SHADOW | CANARY | ENABLED`만 사용하고,
|
||||
drain은 connection lifecycle의 `DRAINING`으로 표현한다.
|
||||
- `DISABLED`는 새 data-plane side effect를 0으로 한다. 이미 소유한 fixed
|
||||
resource의 idempotent close/revoke만 bounded `DRAINING` cleanup plane에서
|
||||
허용하며 `CLOSED` 뒤 network side effect는 0이다.
|
||||
|
||||
hidden에서는 Polling을 중지하고 live connection은 configured bounded grace 뒤
|
||||
close/pause한다. `pagehide`에서 document-owned SSE/WS/Poll을 모두 정리하고
|
||||
`pageshow`/visible 복귀에는 snapshot freshness gate 뒤 새 runtime으로 resume한다.
|
||||
`unload` 완료에 의존하지 않는다. backend는 active authorization revoke를
|
||||
close/control event로 전파하거나 bounded max connection age에 재인가한다.
|
||||
|
||||
### 7. retry owner를 하나로 제한한다
|
||||
|
||||
reconnect는 capped full-jitter exponential backoff를 사용한다. base/max delay,
|
||||
max attempts와 max elapsed는 immutable registry/implementation ceiling으로
|
||||
제한한다.
|
||||
|
||||
- stable-open window 또는 valid heartbeat/event 뒤에만 attempt reset
|
||||
- valid server hint는 local delay보다 이른 retry를 금지하는 not-before bound
|
||||
- server hint가 implementation max/remaining elapsed budget을 넘으면 낮춰
|
||||
clamp하지 않고 degraded/stale로 종료
|
||||
- offline에서는 timer retry를 멈춤
|
||||
- auth expiry는 session owner single-flight recovery 한 번
|
||||
- forbidden/protocol/schema failure는 terminal
|
||||
- 외부 rate/provider failure는 exact bounded server not-before hint가 있을 때만
|
||||
retry하고, hint가 없으면 terminal
|
||||
- retry budget 소진 뒤 declared Polling fallback 또는 stale UI
|
||||
- reconnect는 realtime coordinator, auth는 session owner, Poll cadence는 poll
|
||||
coordinator가 소유하고 Poll-bound HTTP/Query retry는 비활성
|
||||
- recovery checkpoint는 exact branded object identity로 다음 attempt에 전달한다.
|
||||
SSE `onOpen`/WebSocket `onSubscribed` proof와 attempt 성공 proof가 같은
|
||||
object일 때만 common transport barrier를 확인하고 event admission을 연다.
|
||||
clone/missing proof와 30초 readiness deadline 초과는 fail-closed다.
|
||||
- aborted sleep/attempt/closed-receipt는 기본 2초 bounded drain 뒤 run을
|
||||
fail-closed로 끝내되, 실제 old task가 settle할 때까지 `DRAINING`을 유지한다.
|
||||
정상 active session의 `waitClosed`에는 deadline을 두지 않는다.
|
||||
|
||||
### 8. SSE baseline은 bounded fetch-stream이다
|
||||
|
||||
common reference target은 fixed same-origin BFF에 대한 fetch-stream SSE다.
|
||||
native EventSource보다 다음을 명시적으로 제어하기 위해서다.
|
||||
|
||||
- credential integration
|
||||
- status/content type/redirect
|
||||
- AbortSignal과 lifecycle
|
||||
- parser/event byte ceiling
|
||||
- reconnect/idle/retry budget
|
||||
- explicit current cursor
|
||||
|
||||
native EventSource는 same-origin cookie auth, native `Last-Event-ID`/reconnect,
|
||||
`204` terminal contract와 lifecycle 뒤 cursor recovery를 backend가 수용한
|
||||
별도 profile에서만 허용한다. UA cursor를 application effect commit과 묶을 수
|
||||
없으므로 `INVALIDATION_HINT` 전용이고 reconnect/restore마다 authoritative
|
||||
snapshot gate를 수행한다. gate 중 hint는 bounded `pendingInvalidation`으로
|
||||
coalesce하고 checkpoint 뒤 pending refetch까지 drain한다. 이 buffer/barrier가
|
||||
없으면 `CURRENT`를 금지한다. `AUTHORITATIVE_DELTA`는 fetch-stream만 허용한다.
|
||||
token을 URL에 넣지 않는다.
|
||||
|
||||
fetch-stream parser는 표준 UTF-8 SSE format, BOM/line ending/comment/multi-line
|
||||
data/id/retry/incomplete EOF를 bounded하게 구현한다. exact `200
|
||||
text/event-stream`만 stream 성공이며 auth/rate/reset/provider status를 closed
|
||||
failure로 mapping한다. parsed candidate ID와 effect-committed cursor를 분리하고
|
||||
각 application event block의 직접 `id`와 envelope cursor를 exact match한다.
|
||||
|
||||
SSE baseline은 registry-owned session feed 하나와 feed-wide cursor 하나다.
|
||||
route lease는 local dispatch만 바꾸며 arbitrary server multiplex와
|
||||
per-subscription cursor는 `NOT_SELECTED`다.
|
||||
|
||||
hosting은 proxy buffering, idle/request timeout, heartbeat, cache/transform,
|
||||
HTTP connection budget와 client disconnect cleanup을 실제로 검증한다.
|
||||
|
||||
### 9. WebSocket은 versioned duplex protocol로만 선택한다
|
||||
|
||||
- fixed same-origin `wss:` endpoint와 exact subprotocol
|
||||
- server `Origin` 검증과 current session authorization
|
||||
- URL/query/subprotocol에 credential 금지
|
||||
- closed welcome/subscribe/unsubscribe-ack/event/reset/heartbeat/close frame
|
||||
- baseline text JSON, binary/extension은 별도 승인
|
||||
- application heartbeat/watchdog
|
||||
- bounded incoming sequential queue
|
||||
- bounded outgoing queue와 `bufferedAmount`
|
||||
- raw close reason redaction
|
||||
- same-epoch cursor resume의 `nextExpectedSequence = lastApplied + 1`; accepted
|
||||
cursor silent advance 금지, mismatch는 reset/snapshot
|
||||
- state-bearing initial subscribe는 snapshot/checkpoint + barrier 전 `CURRENT` 금지
|
||||
- `UNSUBSCRIBE` 뒤 matching `UNSUBSCRIBED`까지 tombstone과 quota를 유지하고 late
|
||||
event/control은 effect 없이 버린다. unknown ACK와 ACK deadline 초과는
|
||||
connection-level failure다.
|
||||
|
||||
classic browser WebSocket은 incoming backpressure를 제공하지 않으므로 queue
|
||||
overflow에서 임의 delta drop을 하지 않는다. baseline은 connection을 close하고
|
||||
snapshot resync한다. server의 bounded pause/resume ACK protocol을 별도 증명한
|
||||
profile에서만 subscription pause를 허용한다.
|
||||
|
||||
모든 client control frame은 하나의 FIFO outbound queue를 통과한다. negotiated
|
||||
message count/queued bytes와 native `bufferedAmount` 중 하나라도 넘으면
|
||||
`QUEUE_OVERFLOW`, `retryable=false`, `OVERLOADED`로 generation 전체를 닫고
|
||||
snapshot recovery를 요청한다.
|
||||
|
||||
durable business command는 기존 HTTP path를 기본으로 유지한다. WebSocket
|
||||
command를 선택하면 closed operation, command ID/idempotency, expected revision,
|
||||
ack와 business commit certainty를 별도로 정의한다.
|
||||
|
||||
### 10. Web Push는 별도 window/worker/backend capability다
|
||||
|
||||
Web Push 선택에는 다음이 모두 필요하다.
|
||||
|
||||
- user-action 기반 permission UX
|
||||
- active Service Worker registration
|
||||
- `userVisibleOnly: true`인 window subscription manager
|
||||
- authenticated backend register/revoke
|
||||
- server subscription registry
|
||||
- VAPID private-key/provider owner
|
||||
- worker push/notification/click inbound adapters
|
||||
|
||||
PushSubscription endpoint, `p256dh`, `auth`는 capability material로 취급하고
|
||||
application state, browser storage, URL, BroadcastChannel과 telemetry에서
|
||||
금지한다. VAPID private key는 server-only다.
|
||||
|
||||
push payload는 versioned, association/release-bound, expiring opaque notification
|
||||
hint로 제한한다. 개인 내용은 foreground BFF가 current authorization으로
|
||||
조회한다. worker handler는 `waitUntil` 안에서 bounded validation과
|
||||
`showNotification`만 수행하며 long retry/sync/migration을 하지 않는다.
|
||||
decoded application hint는 3 KiB를 넘지 않으며 최상위 JSON member name 중복은
|
||||
last-wins로 해석하지 않고 거절한다. `issuedAt`의 client clock 대비 future
|
||||
skew는 최대 5분, `expiresAt - issuedAt` lifetime은 최대 24시간이다.
|
||||
window의 native permission/subscription operation은 30초, backend
|
||||
register/reconcile/revoke operation은 15초 안에 종료하며 제품 config는 이
|
||||
implementation ceiling을 높일 수 없다.
|
||||
`pushsubscriptionchange` window handoff도 worker lifecycle abort와 10초
|
||||
deadline을 사용하고, non-cooperative `matchAll()` 또는 동기 `waitUntil()` 예외
|
||||
뒤에는 늦은 `postMessage`를 허용하지 않는다.
|
||||
|
||||
notification copy와 click route는 closed registry를 사용한다. arbitrary backend
|
||||
text나 URL을 OS notification/openWindow에 전달하지 않는다.
|
||||
worker restart 뒤 click을 처리하도록 bounded non-sensitive
|
||||
`NotificationClickDataV1`만 `NotificationOptions.data`에 넣고 click 시
|
||||
codec/expiry/current association/release를 다시 검증한다. logout 때 owned
|
||||
notification은 bounded best-effort close하지만 OS 잔존 가능성 때문에 copy는
|
||||
항상 account-neutral이어야 한다.
|
||||
|
||||
worker는 window in-memory session을 authority로 사용할 수 없으므로 opaque
|
||||
`fenceGeneration`, `sessionBindingEpoch`, `releaseEpoch`과
|
||||
`UNASSOCIATED | ACTIVE | REVOKED` association discriminant를 가진
|
||||
adapter-owned IndexedDB `PUSH_CONTROL_V1` record를 사용한다.
|
||||
account ID, endpoint/key, credential과 notification content는 이 record에서
|
||||
금지한다. missing/corrupt/mismatch는 fail-closed한다. 동일 association epoch의
|
||||
`REVOKED`는 terminal tombstone이다. logout은 durable fence generation rotate와
|
||||
REVOKED를 먼저 commit한다. 새 `ACTIVE`는 distinct backend epoch와 captured/current
|
||||
fence generation, server session binding, prior record revision/epoch, release를
|
||||
한 IDB transaction에서 CAS해 stale-tab response를 거절한다. 첫 register 전
|
||||
`UNASSOCIATED` record도 같은 generation을 durable하게 보관하므로 logout과
|
||||
in-flight register response의 race를 association sentinel 없이 닫는다. client
|
||||
`updatedAt`은 ordering authority가 아니다.
|
||||
|
||||
logout은 old generation fence, durable local association `REVOKED` commit과
|
||||
backend account association revoke를 정상 security commit으로 사용한다. boot에서
|
||||
native subscription/local fence/server association을 reconcile하고, local commit
|
||||
실패나 ambiguous revoke는 `PUSH_UNAVAILABLE`로 내려 짧은 TTL, send-time auth와
|
||||
click-time 재인가에 의존한다. native unsubscribe/old notification close는
|
||||
best-effort지만 captured native subscription, exact association tag와 unchanged
|
||||
durable fence를 모두 다시 확인한 경우에만 수행한다. 새 association이 commit되면
|
||||
old cleanup은 건너뛴다. local fence 실패 뒤 current native subscription 조회나
|
||||
association wildcard cleanup은 금지한다.
|
||||
|
||||
control tombstone purge는 자동 revoke 단계가 아니다. 별도 maintenance owner만
|
||||
captured revision/authority/association epoch가 exact한 `REVOKED` record를
|
||||
repository CAS로 삭제할 수 있고, concurrent newer owner가 있으면
|
||||
`STALE_REVISION`으로 끝난다.
|
||||
|
||||
backend register/revoke는 VD-23의 fixed `COMMAND`로 등록하고 cookie session의
|
||||
exact CSRF를 검증한다. register는 keyed idempotency 또는 atomic installation
|
||||
upsert/receipt, revoke는 duplicate/`ALREADY_GONE` 성공 의미를 가져야 하며
|
||||
`associationEpoch`은 server commit 뒤에만 발급한다.
|
||||
|
||||
Service Worker를 우회해 UA가 직접 notification을 표시할 수 있는 declarative push
|
||||
message는 V1에서 `NOT_SELECTED`다. outbound `web_push: 8030` shape를 거절하고
|
||||
별도 ADR 전에는 encrypted `WEB_PUSH_HINT_V1`만 허용한다.
|
||||
|
||||
Service Worker를 선택해도 offline fetch, PWA shell cache나 background sync가
|
||||
자동 승인되지 않는다. 하나의 worker composition/update owner가 선택된 handler를
|
||||
조립한다.
|
||||
|
||||
### 11. Polling은 bounded lease다
|
||||
|
||||
허용 형태:
|
||||
|
||||
- visible query의 낮은 빈도 conditional freshness poll
|
||||
- 사용자 시작 async job의 terminal-state convergence poll
|
||||
|
||||
각 lease는 operation owner, minimum/success/max interval, max attempts,
|
||||
max elapsed, response byte cap, visible-only policy와 terminal states를 가진다.
|
||||
|
||||
- operation은 registered terminal·replay-safe REST `QUERY`여야 함
|
||||
- Poll `maxAttempts`는 physical request 하나인 logical completion을 셈
|
||||
- Poll-bound VD-23 budget은 `maxAttempts=1`, `authRecoveryCount=0`,
|
||||
`maxCumulativeSleepMs=0`; TanStack Query retry도 끔
|
||||
- completion-chained timeout으로 single-flight
|
||||
- hidden/offline/pagehide/unmount/scope change/user cancel에서 stop
|
||||
- ETag/`If-None-Match` 또는 server cursor 사용
|
||||
- `304`, auth, cursor reset, `429/503 Retry-After`를 closed mapping
|
||||
- common recovery coordinator가 `POLL_ACTIVE -> LIVE_PROBING`에서 poll만 effect
|
||||
writer로 유지하고 live candidate는 bounded buffer만 사용. handoff mutex에서
|
||||
poll fence/abort + quiescence를 먼저 완료하고 current-generation
|
||||
snapshot/checkpoint와 buffered event를 적용한 뒤 live를 활성화
|
||||
- active writer effect tail도 in-flight 포함 256건/4MiB로 제한하고 overflow는
|
||||
전체 generation을 `QUEUE_OVERFLOW`로 fail-close
|
||||
- budget 소진 뒤 manual refresh/stale UI
|
||||
- page component `setInterval`과 unlimited loop 금지
|
||||
|
||||
### 12. resource ceiling과 privacy를 fail-closed한다
|
||||
|
||||
상세 설계의 target hard ceiling은 physical connection, logical subscription,
|
||||
event/frame/parser/queue/dedupe/reorder/outbound buffer, reconnect, poll lease,
|
||||
push hint와 worker deadline을 제한한다. 제품 config는 더 작게만 설정할 수 있다.
|
||||
|
||||
2026-07-28 reference-runtime amendment로, RT-01~RT-04 source 전체를
|
||||
tree-shaking 없이 합성하는 optional-recipe gzip 예산을 40,000 bytes로
|
||||
고정한다. 이는 production bundle 허용량이 아니며 미선택 production asset의
|
||||
realtime module 허용량은 계속 0이다. SSE replay-open과 WebSocket
|
||||
`SUBSCRIBED`가 exact recovery checkpoint를 증명하고 common barrier가 확인될
|
||||
때까지 event admission을 막는 readiness gate는 attempt당 최대 30초다.
|
||||
phase abort 뒤 비협조적인 retry sleep, connect attempt 또는 closed-receipt
|
||||
cleanup을 기다리는 drain은 2초로 고정하고 구현 절대 최대는 30초다. 상한을
|
||||
넘긴 task가 settle할 때까지 lifecycle은 `DRAINING`을 유지하며 정상 active
|
||||
session의 `waitClosed`에는 이 cleanup deadline을 적용하지 않는다.
|
||||
|
||||
ceiling 초과는 limit 자동 인상이나 silent drop이 아니라 new lease rejection,
|
||||
connection close, snapshot resync, typed backpressure, stale/degraded 또는
|
||||
notification drop으로 처리한다.
|
||||
|
||||
telemetry에는 transport/registry ID, closed outcome, count/duration/lag bucket만
|
||||
허용한다. raw URL/query/credential/subject/event ID/cursor/payload/close reason/
|
||||
PushSubscription key와 notification private content는 금지한다.
|
||||
|
||||
### 13. 실제 provider/browser/operations evidence 전에는 promotion하지 않는다
|
||||
|
||||
evidence를 분리한다.
|
||||
|
||||
1. pure unit/property와 deterministic fault contract
|
||||
2. 실제 local SSE/WS server integration
|
||||
3. backend replay/snapshot/auth/hosting/provider conformance
|
||||
4. built production asset의 target-browser lifecycle
|
||||
5. Web Push provider + browser/OS 자동·수동 evidence
|
||||
6. load/chaos/security negative gate
|
||||
7. dashboards, kill switch와 drain/recovery/rollback drill
|
||||
|
||||
fake/jsdom/MSW만으로 native stream, socket, worker, notification이나 provider
|
||||
readiness를 주장하지 않는다. 외부 evidence가 없으면 `PromotionEvidence`는
|
||||
`MISSING | PARTIAL`이고 promotion gate result는 `FAIL_UNVERIFIED`다.
|
||||
|
||||
## 선택하지 않은 대안
|
||||
|
||||
### 범용 transport enum을 가진 `RealtimePort`
|
||||
|
||||
전송 교체는 가능해 보이지만 direction, permission, lifecycle, delivery certainty와
|
||||
fallback 의미를 잃는다. 공통 protocol coordinator만 재사용하고 native capability
|
||||
port는 분리한다.
|
||||
|
||||
### 모든 server event에 WebSocket 사용
|
||||
|
||||
one-way notification에도 duplex handshake, heartbeat, queue와 server connection
|
||||
운영 비용을 강제한다. one-way stream은 SSE를 우선 검토한다.
|
||||
|
||||
### native EventSource만 공통 baseline으로 사용
|
||||
|
||||
arbitrary auth header, detailed status mapping, bounded reconnect와 explicit
|
||||
lifecycle cursor 제어가 부족하다. 조건부 profile로는 허용하지만 reference
|
||||
baseline은 fetch-stream이다.
|
||||
|
||||
### token을 SSE/WS URL에 전달
|
||||
|
||||
history, log, proxy, analytics와 referrer에 노출될 수 있다. same-origin
|
||||
BFF/cookie 또는 승인된 별도 handshake를 사용한다.
|
||||
|
||||
### event payload로 Query cache 직접 patch
|
||||
|
||||
filter/pagination/revision/gap 의미가 없으면 stale projection을 만든다. 기본은
|
||||
namespace invalidation과 authoritative refetch다.
|
||||
|
||||
### Web Push를 silent sync로 사용
|
||||
|
||||
permission/browser/OS/provider가 background execution과 timely delivery를
|
||||
보장하지 않는다. user-visible notification hint와 foreground refresh로 제한한다.
|
||||
|
||||
### 무한 `setInterval` Polling
|
||||
|
||||
overlap, hidden resource 사용, retry 중첩과 terminal cleanup 누락을 만든다.
|
||||
finite immutable lease와 single owner를 사용한다.
|
||||
|
||||
### cross-tab leader를 기본 제공
|
||||
|
||||
leader election/crash/handoff/partition과 SharedWorker 지원이 별도 protocol을
|
||||
요구한다. 기본은 tab별 bounded runtime과 focus snapshot이다.
|
||||
|
||||
### exactly-once delivery
|
||||
|
||||
cursor commit과 application effect 사이 crash window, push service와 browser
|
||||
lifecycle을 공통 frontend만으로 제거할 수 없다. retention 안의 CURSOR event만
|
||||
duplicate-tolerant하게 처리하고 나머지는 best-effort + authoritative resync를
|
||||
사용한다.
|
||||
|
||||
## 결과
|
||||
|
||||
긍정적 결과:
|
||||
|
||||
- 전송 선택이 요구와 failure semantics에 연결된다.
|
||||
- server state/query ownership과 clean architecture 경계를 유지한다.
|
||||
- gap, late callback, logout과 page restore가 명시적 복구 경로를 가진다.
|
||||
- Web Push permission/subscription material이 일반 realtime state와 분리된다.
|
||||
- Polling fallback이 resource-unbounded loop가 되지 않는다.
|
||||
- 미선택 capability의 bundle/worker/runtime side effect를 0으로 유지할 수 있다.
|
||||
|
||||
비용:
|
||||
|
||||
- common coordinator 외에도 transport별 adapter와 실제 provider harness가 필요하다.
|
||||
- backend는 replay/snapshot/outbox/auth와 provider 운영 계약을 제공해야 한다.
|
||||
- worker와 window에 별도 composition/test matrix가 필요하다.
|
||||
- direct delta보다 invalidation/refetch가 추가 HTTP 비용을 만들 수 있다.
|
||||
- target browser/OS에서 자동화할 수 없는 Web Push evidence를 운영해야 한다.
|
||||
|
||||
## 구현 순서
|
||||
|
||||
```text
|
||||
RT-00 contract/status
|
||||
-> RT-01 event authority + scope/gap/resync
|
||||
-> RT-02 SSE + bounded polling
|
||||
-> RT-03 WebSocket
|
||||
-> RT-04 Web Push
|
||||
-> RT-05 product composition/provider/browser/operations
|
||||
```
|
||||
|
||||
SSE와 WebSocket을 모두 구현해야 skeleton이 완성되는 것은 아니다. 공통
|
||||
mechanism을 구현한 뒤 실제 product requirement에 필요한 최소 transport만
|
||||
선택한다.
|
||||
|
||||
reference source와 deterministic/native evidence가 생기면 해당 runtime만
|
||||
`AVAILABLE_NOT_COMPOSED`로 올린다. 제품 endpoint/event registry/policy가
|
||||
bootstrap에 연결된 transport만 `COMPOSED`다.
|
||||
|
||||
## Rollout
|
||||
|
||||
capability별 traffic admission:
|
||||
|
||||
```text
|
||||
DISABLED -> SHADOW -> CANARY -> ENABLED
|
||||
SHADOW | CANARY | ENABLED -> DISABLED
|
||||
```
|
||||
|
||||
- transport, stream, Poll fallback과 push category kill switch를 분리한다.
|
||||
- safe config default는 `DISABLED`다.
|
||||
- canary 전에 backend/provider/browser/operations evidence를 만료 검증한다.
|
||||
- deploy/drain과 reconnect herd를 load test한다.
|
||||
- freshness/latency만 아니라 gap/resync/queue/memory/battery/push permission
|
||||
지표를 함께 본다.
|
||||
|
||||
## Rollback과 제거
|
||||
|
||||
1. admission을 `DISABLED`, connection lifecycle을 `DRAINING`으로 전환한다.
|
||||
2. logical subscription/send/poll/push registration을 중지한다.
|
||||
3. active reader/socket/timer/handler를 bounded close한다.
|
||||
4. HTTP focus/manual refresh 또는 명시된 fallback을 노출한다.
|
||||
5. server publisher/replay/subscription compatibility window를 유지한다.
|
||||
6. composition/registry/adapter/dependency/worker handler를 제거한다.
|
||||
7. CSP/runtime config/provider key와 retained server subscription을 정리한다.
|
||||
8. typecheck, architecture, tests, build, bundle/module inventory와 removal gate를
|
||||
실행한다.
|
||||
|
||||
미선택/제거 상태에서 connection, timer, push listener/subscription request와
|
||||
production bundle sentinel이 0이어야 한다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
### 이 결정의 설계 완료
|
||||
|
||||
- [x] 네 capability의 의미와 선택 조건을 분리했다.
|
||||
- [x] current status와 target runtime 상태를 구분했다.
|
||||
- [x] source of truth와 delivery/effect certainty를 정했다.
|
||||
- [x] target envelope, ordering, cursor와 resync를 정했다.
|
||||
- [x] lifecycle/retry/resource/security/privacy 경계를 정했다.
|
||||
- [x] transport별 auth/hosting/worker/Poll contract를 정했다.
|
||||
- [x] evidence, rollout, rollback과 제거 기준을 정했다.
|
||||
|
||||
### 구현과 promotion 상태
|
||||
|
||||
- [x] RT-01 공통 coordinator/reconnect/contract suite
|
||||
- [x] RT-02 SSE/Poll 및 single-writer handoff reference runtime과 deterministic evidence
|
||||
- [x] RT-03 WebSocket reference runtime과 deterministic evidence
|
||||
- [x] RT-04 Web Push window/worker reference runtime과 deterministic evidence
|
||||
- [x] static boundary/security fixture, synthetic bundle budget와 removal blocking gate
|
||||
- [ ] actual SSE/WS local server, load와 target-browser evidence
|
||||
- [ ] actual Web Push provider, permission UX와 target-browser evidence
|
||||
- [ ] provider/browser evidence와 operations drill의 release-blocking gate 등록
|
||||
- [ ] 실제 product/backend/provider selection
|
||||
- [ ] operations runbook drill
|
||||
|
||||
common runtime status는 `AVAILABLE_NOT_COMPOSED`다. 위의 미완료 promotion
|
||||
항목 전에는 product selection이 계속 `NOT_SELECTED`이고 production-ready를
|
||||
주장하지 않는다.
|
||||
|
||||
## 관련 자료
|
||||
|
||||
- [상세 설계](../realtime-events-web-push-and-bounded-polling.md)
|
||||
- [VD-10 optional capability recipes](./VD-10-optional-capability-recipes.md)
|
||||
- [VD-13 client cache scope and persistence](./VD-13-client-cache-scope-and-persistence.md)
|
||||
- [VD-23 API transport selection and REST execution](./VD-23-api-transport-selection-and-rest-execution.md)
|
||||
- [VD-25 Server State Cache lifecycle](./VD-25-server-state-cache-lifecycle.md)
|
||||
- [VD-27 gRPC-Web unary and server stream](./VD-27-grpc-web-unary-and-server-stream.md)
|
||||
- [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md)
|
||||
- [Optional adapter recipes](../optional-adapter-recipes.md)
|
||||
- [Client cache and browser storage](../client-cache-and-storage.md)
|
||||
- [Frontend ports, adapters, and boundaries](../frontend-ports-adapters-and-boundaries.md)
|
||||
- [WHATWG Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html)
|
||||
- [WHATWG WebSockets](https://websockets.spec.whatwg.org/)
|
||||
- [W3C Push API](https://www.w3.org/TR/push-api/)
|
||||
- [WHATWG Notifications API](https://notifications.spec.whatwg.org/)
|
||||
- [W3C Service Workers](https://www.w3.org/TR/service-workers/)
|
||||
- [RFC 8030](https://www.rfc-editor.org/rfc/rfc8030)
|
||||
- [RFC 8291](https://www.rfc-editor.org/rfc/rfc8291)
|
||||
- [RFC 8292](https://www.rfc-editor.org/rfc/rfc8292)
|
||||
Reference in New Issue
Block a user