R-02: add an OPEN/DRAINING/CLOSED lifecycle orthogonal to freshness. Effect and recovery authorities are now awaited under a deadline: on expiry the commit capability is revoked and the work aborted, the caller gets a bounded non-retryable IDLE_TIMEOUT, and the underlying task is retained rather than dropped. A draining stream refuses new events and recovery, and close() returns a Promise that succeeds only once every retained task actually settled, reporting IDLE_TIMEOUT otherwise. R-03: a handoff fail-close moves active, probe, quiescing and transition leases into a retired-writer set before clearing their references, and close() waits on current and retired writers together, so an abandoned non-cooperative writer can no longer make teardown report a false success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
661 lines
34 KiB
Markdown
661 lines
34 KiB
Markdown
# 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이 바뀔 때
|
|
|
|
|
|
## 스트림 lifecycle은 freshness와 직교한다 (R-02, R-03)
|
|
|
|
`RealtimeStreamLifecycle = OPEN | DRAINING | CLOSED`는 freshness
|
|
(`UNKNOWN/CURRENT/STALE/RESYNCING`)와 별개다.
|
|
|
|
- effect/recovery deadline에 도달하면 commit capability를 즉시 영구 무효화하고
|
|
abort한다. caller에는 bounded `IDLE_TIMEOUT`(non-retryable, operation
|
|
`APPLY`/`RECOVER`)을 반환하되 **실제 task는 버리지 않고 retain**한다.
|
|
- retain된 task가 하나라도 있으면 stream은 `DRAINING`이고 새 event/recovery
|
|
admission을 거절한다. 실제 settlement가 일어나야 `STALE`로 돌아가
|
|
authoritative recovery를 요구하거나, close 요청이면 `CLOSED`가 된다.
|
|
- `close()`는 `Promise<RealtimeResult<void>>`다. 모든 retain task가 실제로
|
|
settle해야 success이고, drain bound를 넘기면 `IDLE_TIMEOUT/CLOSE`를 반환하며
|
|
stream은 계속 `DRAINING`이다. teardown success가 곧 quiescence다.
|
|
- LIVE↔POLL overflow fail-close는 active/probe/quiescing/transition lease를
|
|
모두 abort한 뒤 **retired writer set**으로 옮기고 나서 reference를 지운다.
|
|
`close()`는 current와 retired를 dedupe해 함께 기다리므로, 버려진
|
|
non-cooperative writer가 아직 실행 중인데 close가 성공을 보고할 수 없다.
|
|
|
|
## 배경
|
|
|
|
현재 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)
|