High 둘은 실제 유실 경로였다. - withheld를 페이로드로 그대로 넘기면 재진입에 깨진다. 전파 중 Observer가 Set을 불러 게이트에 재진입하면 중첩 전파가 끝나며 table.clear가 돌아 바깥 전파의 남은 갈래가 빈 집합을 받는다. 전파 직전에 새 테이블로 스왑해 배치를 떼어내고 그 배치를 페이로드로 넘기는 것으로 수정. - OffWithoutEmit()이 집합을 안 비웠다. Dispatch.drive의 배치 게이팅이 매 프레임 On() -> OffWithoutEmit()을 돌므로 집합이 단조 증가하고(weak 설계와도 충돌) 나중에 아무 소스나 통과할 때 폐기분이 같이 실려 나간다. 그 경로도 비우도록 확정하고 withheld를 weak key로 명시. 나머지 수정 셋: - "무조건 withheld에 넣는다"가 수신 규칙 1~3을 건너뛴다는 뜻으로 읽히던 것을 "정책의 통과/유보와 무관하게"로 명시(그대로 두면 다이아몬드에서 Throttle 정책이 두 번 돌아 유령 trailing emit이 나간다) - luau-test/STATUS.md의 "런타임 12개"가 이미 나간 04/10/19를 포함한 옛 총계에서 이어져 온 수라 실제(9건)와 안 맞던 것 - followup D절 색인 표가 삭제된 research/ 두 문서를 현재형으로 서술하던 것 열린 항목으로 승격 둘(question.md의 "남은 것은 사용자 판단이 아니다"도 정정): - Gate 재진입 계약 — 스냅샷으로 유실은 막았으나 정책 안 재귀 호출 계약은 미정 - 소스 없는 emit(빈 배치) — 정책이 상류 신호와 무관하게 emit()을 부르면 빈 배치가 나가 하류가 조용히 삼킨다. Effect(fn, ...deps) 설치 구간 억제 용례가 정확히 이 모양이라 그대로는 성립하지 않음. 권고는 "빈 배치 = 무조건 통지" 처리 전량은 round5-followup.md의 T절. doc-check.py ERROR 0. Co-authored-by: qwreey <me@qwreey.moe> Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
238 lines
18 KiB
Markdown
238 lines
18 KiB
Markdown
# `Gate` — emit을 가로채는 게이트 노드 (2026-08-21 확정)
|
|
|
|
**상태**: **표면 확정.** `Blocker`/`Debounce`/`Throttle`이 공유하는 게이팅
|
|
메커니즘을 `state:Gate(setup)` **메소드**로 확정했다 — *"Gate 는 따로 프리미티브
|
|
없이 `state:Gate( (emit) -> ()->() )` 처럼 선언되고 마치 Compute 처럼
|
|
GateNode(ComputeNode 처럼) 생성된다 그리고 Blocker 는 해당 내부 배선을 따른다
|
|
← 동의합니다 해당 방법대로 확정하면 됩니다."* 구현은 **M2**("게이팅 먼저"
|
|
결정, `ROADMAP.md`). **남은 것은 아래 "아직 안 정한 것"의 생명주기·재진입 계약과 M2
|
|
범위뿐** — `/code-review high`가 잡았던 4번(유보된 emit이 싣는 출처)은 같은
|
|
날 `emit(self)` + 흡수 집합으로 닫혔고, **`setup` 시그니처는 안 바뀌었다.**
|
|
|
|
**⚠️ 처음 방향이 한 번 바뀌었다.** 신설 당시엔 *"공용 `Gate` 프리미티브를 꺼내고
|
|
`Blocker`가 그걸 컴포지션한다"*였는데, 확정된 형태는 **프리미티브를 따로 안
|
|
만들고 State 메소드 하나로 끝낸다**이다 — *"Gate 프리미티브를 만들고 Blocker 가
|
|
컴포지션 하는걸 생각했는데, 그럴 필요가 없네요. `:Gate` 는 또 Apply 에서 쓸만한
|
|
표면을 주기도 하구요."* 아래 본문 중 "공개 프리미티브로 꺼낸다"류 서술은 그
|
|
이전 시점 표현이니 이 배너 기준으로 읽을 것.
|
|
|
|
**한 줄**: `Blocker`가 쓰던 "게이티드 State 노드"를 한 겹 일반화해서, **상류
|
|
emit을 가로채 내려보낼지 말지를 정책이 정하는** 노드를 **`state:Gate(setup)`
|
|
공개 메소드**로 낸다(탑레벨 생성자가 아니라 — 위 배너 참고).
|
|
`Blocker`/`Debounce`/`Throttle`이 그 위에 얹히는 서로 다른 정책이 된다.
|
|
|
|
## 왜 지금인가 — 두 갈래가 같은 자리를 가리켰다
|
|
|
|
1. **`CR-3`(마일스톤 순서)** — `Dispatch.drive`의 배치 등록이 Blocker 게이팅을
|
|
전제하므로 M2가 M3의 `Blocker.luau`에 구조적으로 의존한다. 사용자 결정:
|
|
**게이팅을 먼저 만든다.**
|
|
2. **`DT-4`(Debounce/Throttle)** — 공개 `Blocker` API 위에는 시간 기반 게이트를
|
|
못 얹는다. `base/debounce-throttle-plan.md`가 이미 "게이티드 노드를 내부 공용
|
|
`Gate`로 일반화하고 그 위에 정책을 얹으라"고 권고해뒀고, M3에서 Blocker를
|
|
만들 때 같이 해두지 않으면 같은 설계를 두 번 하게 된다.
|
|
|
|
**사용자 논거(`DT-4`)** — Blocker + Observer 조합으로는 왜 안 되는가:
|
|
*"스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐
|
|
후행 Blocker 로 생성된 요소의 emit 이 먼저이냐가 문제되기 때문에 Blocker/Observer
|
|
가지고는 구현 못 한다. 순서를 보존해야한다는 전재가 생기는데 중간이 비어 해시가
|
|
되면 이를 전혀 못 지키기 때문."* → 게이트가 **emit 경로 자체에 끼어들어야**
|
|
하고, 바깥에서 관측만 해서는 순서를 보장할 수 없다.
|
|
|
|
**공개 여부**: 사용자 판단 — *"이 API가 비공개일 이유는 없어보인다."* 즉
|
|
내부 배관이 아니라 **공개 표면**으로 낸다. **[2026-08-21]** 다만 그 표면은
|
|
탑레벨 프리미티브가 아니라 **State 메소드 `:Gate`** 다(아래 2번).
|
|
|
|
## 제안된 모양 (사용자 스케치 그대로)
|
|
|
|
```lua
|
|
Gate(function(emit)
|
|
-- 이 `emit`은 setup 밖으로 캡처해 **언제든** 부를 수 있다(타이머 콜백 등).
|
|
return function()
|
|
-- 상류 emit이 도착할 때마다 호출된다.
|
|
-- 여기서 `emit()`을 부를지 말지는 정책 마음.
|
|
end
|
|
end)
|
|
```
|
|
|
|
- `setup(emit) -> onUpstreamEmit` 2단 구조. 바깥 함수는 게이트 인스턴스가
|
|
만들어질 때 1회, 반환된 함수는 상류 emit마다.
|
|
- `Blocker`는 이 위의 정책 하나가 된다 — "켜져 있으면 `emit()`을 안 부르고
|
|
플래그만 세워두고, 꺼질 때 한 번 부른다"(`HasBlockedEmit`이 그 플래그).
|
|
- `Debounce`/`Throttle`도 정책 — 타이머를 걸고 창이 끝날 때 `emit()`.
|
|
|
|
## 아직 안 정한 것 (사용자 판단 필요)
|
|
|
|
1. **[2026-08-21 해소] 이름 — `Gate`.** 원래 걸림돌은 *"프리미티브 명을
|
|
Gater? 뭔가 이상하게 들어간다"*였는데, **탑레벨 프리미티브를 안 만들기로
|
|
하면서 문제 자체가 사라졌다** — 이름이 놓이는 자리가 `Blocker()` 같은
|
|
생성자가 아니라 `state:Gate(...)` **메소드**이고, 메소드 자리에서 `Gate`는
|
|
`:With`/`:Compute`/`:Observer`와 나란히 자연스럽다. 노드 타입 이름은
|
|
`ComputeNode`와 짝을 맞춘 **`GateNode`**.
|
|
2. **[2026-08-21 해소] `:Apply`가 아니라 State의 메소드다 — `state:Gate(setup)`.**
|
|
**확정 형태**:
|
|
|
|
```lua
|
|
-- setup: (emit: () -> ()) -> (onUpstreamEmit: () -> ())
|
|
local gated = state:Gate(setup) -- ComputeNode처럼 GateNode를 하나 만든다
|
|
```
|
|
|
|
`Blocker`는 **그 위에 얹히는 별개 프리미티브**로, `state:Block(blocker)`가
|
|
내부에서 이 배선을 그대로 쓴다. 탑레벨 `Gate(...)` 생성자는 **안 만든다.**
|
|
`:Gate`가 메소드라고 `:Apply`와 배타적인 것도 아니다 — 사용자 지적대로
|
|
*"`:Gate` 는 또 Apply 에서 쓸만한 표면을 주기도"* 하므로,
|
|
`Debounce{...}` 같은 유저랜드 팩토리가 내부에서 `s:Gate(policy)`를 부르는
|
|
형태로 `state:Apply(Debounce{...})` 관용구가 그대로 성립한다.
|
|
|
|
**사용자 확정**: *"gate 는 apply 불가하다고 판단함. 순수 슈가가 아니기 때문,
|
|
state 의 전파를 손대는 작업이라 with 처럼 다른 노드가 나는게 맞음."*
|
|
- **정확한 경계**: `Apply`는 `factory(self)`일 뿐이라(`base/source-state-plan.md`의
|
|
"`state:Apply(factory)`" 절) 팩토리가 노드를 만드는 것 **자체는** 금지가
|
|
아니다 — 확정 예시의 `capAt(100)`도 `:With` 노드를 만든다. 갈리는 지점은
|
|
**누가 프리미티브인가**다: `:With`/`:Compute`처럼 **전파 경로에 새 종류의
|
|
노드를 끼우는 것은 State의 메소드**, 그 프리미티브들을 조합한 **유저랜드
|
|
팩토리는 `:Apply`**. `Gate`는 전자다.
|
|
- **그래서 `Debounce`/`Throttle`은 `:Apply` 그대로 둔다** —
|
|
`Debounce{...}`가 돌려주는 팩토리가 내부에서 `s:Gate(policy)`를 부르면
|
|
되므로 `base/debounce-throttle-plan.md`의 확정 관용구는 안 건드려도 된다.
|
|
- **`Blocker` 배선 문제도 같이 사라진다.** `base/blocker-plan.md`가 이미
|
|
**`state:Block(blocker) -> state`(새 gated state 반환)** 라는 **메소드**로
|
|
확정해뒀으므로, `Block`이 내부에서 `self:Gate(blocker의 정책)`을 부르는
|
|
것으로 끝난다 — `blocker` 객체를 `Apply`에 넘길 일이 없다.
|
|
- **`__call`은 안 쓴다.** 사용자도 *"이상적이여 보이지는 않음"*이라 했고,
|
|
타입 쪽 근거가 하나 더 있다 — `__call` 테이블이 Luau에서 `(State<T>) -> U`
|
|
함수 타입 자리에 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력).
|
|
`Apply`를 쓸 이유 자체가 없어졌으므로 확인할 필요도 없어졌지만, 혹시
|
|
되살아나면 `luau-test` 스파이크 한 개로 판정할 것.
|
|
- **2단 구조는 그대로 유효하다** — 사용자 관찰(*"Gate 의 callback 으로 얻어진
|
|
emit과, 리턴해낸 클로저가 호출되는걸로 배선은 가능"*) 대로, 바깥 함수가
|
|
**그 노드의 `emit`을 캡처**하고 반환 클로저가 상류 emit마다 정책을 태운다.
|
|
`Blocker`처럼 **여러 노드가 공유하는 정책**은 공유 상태를 바깥 객체가 들고,
|
|
노드별 `emit`만 2단 구조로 받아 등록하면 된다.
|
|
3. **[2026-08-21 확정] `Get()`과의 관계 — 값이 아니라 통지만 막는다.**
|
|
`Blocker`가 이미 그렇고(`base/blocker-plan.md`의 "`:Get()`엔 영향 없음"),
|
|
`Debounce`/`Throttle`도 emit-gate로 확정돼 있어(`base/debounce-throttle-plan.md`
|
|
§4) `Gate`도 같은 계약으로 통일한다. 공개 계약 문구: **"게이트를 통과하지
|
|
않은 값도 `:Get()`으로는 보인다."**
|
|
`base/state-epoch-plan.md`가 이 계약에 **의존**한다 — 그 문서 §5의 3번이
|
|
"게이트를 에포크 경계로 만드는" 대안을 기각한 이유가 정확히 이 계약을
|
|
뒤집지 않기 위해서다.
|
|
4. **[2026-08-21 해소] 게이트가 유보했다 내보내는 emit — `emit(self)` + 흡수
|
|
집합.** 문제는 실재했다: 확정 `setup`은 `(emit: () -> ()) -> (() -> ())`라
|
|
양쪽 다 source를 안 받는데 `base/state-epoch-plan.md`의 수신 규칙은 전부
|
|
`[source]` 키로 판정하므로, `blocker:Off()`가 묶어뒀던 배치 emit이 아무
|
|
원천도 못 지목한 채 도착해 **하류에서 삼켜진다**(2026-08-21
|
|
`/code-review high` 발견).
|
|
|
|
**확정 기제**(사용자 안, 에이전트가 냈던 (a) `nil` 전체 확인 / (b) 소스마다
|
|
개별 emit은 기각 — 각각 O(1) 판정을 깨거나 `Blocker`의 "정확히 1회"를 깬다):
|
|
|
|
- `GateNode`가 **자기를 거쳐간 소스 집합**을 들고 있는다 —
|
|
`withheld : { [source] : true }`, **weak key**(소스 맵과 같은 이유,
|
|
`state-epoch-plan.md` §5의 5번).
|
|
- **⭐ [2026-08-21 단순화] 통과와 유보를 구분하지 않는다.** 상류 emit이
|
|
오면 **정책을 실행하기 전에 무조건** 그 출처를 `withheld`에 넣고, 그
|
|
다음 정책을 실행한다. 정책이 `emit()`을 부르면 게이트가 하류에 전파하고,
|
|
안 부르면 집합에 그대로 쌓인다.
|
|
- **⚠️ [2026-08-21 `/code-review high`] "무조건"은 *정책의 통과/유보와
|
|
무관하게*라는 뜻이지 *수신 규칙을 건너뛴다*는 뜻이 아니다.** 게이트도
|
|
평범한 노드처럼 `state-epoch-plan.md` §2의 규칙 1~3을 **먼저** 적용하고,
|
|
3번(둘 다 같음)으로 삼켜진 emit은 **정책도 안 돌고 집합에도 안
|
|
들어간다.** 안 그러면 다이아몬드(`A→B→G`, `A→C→G`)에서 `A:Set()` 한
|
|
번에 정책이 두 번 돌아, `Throttle`의 leading 통과 직후 두 번째 emit이
|
|
`pending`을 세워 **이미 전달한 변경에 대한 유령 trailing emit**이
|
|
나간다.
|
|
- **⭐⭐ [2026-08-21 `/code-review high` — 재진입 안전] 전파 페이로드는
|
|
`withheld` 자체가 아니라 그 자리에서 떼어낸 스냅샷이다.** "전파가 반환된
|
|
뒤에 `table.clear`"는 **재진입이 없을 때만** 성립한다 — `G`가 두 갈래로
|
|
전파하다 첫 갈래의 Observer가 `B:Set()`을 부르면(quad는 Observer 안
|
|
`Set`을 허용) 그 파동이 `G`에 다시 도달해 중첩 전파가 끝나며 `clear`가
|
|
돌고, **바깥 전파의 남은 갈래는 빈 집합을 받아** 원래 변경을 영영 못
|
|
받는다. 그래서 전파 직전에 **스왑**한다:
|
|
```
|
|
local batch = self._withheld
|
|
self._withheld = {} -- 새 테이블. clear가 아니다
|
|
emitDownstream(self, batch) -- 떼어낸 batch를 페이로드로 넘긴다
|
|
```
|
|
하류가 순회하는 것은 `gate._withheld`가 아니라 **받은 `batch`** 다.
|
|
재진입이 나도 그 파동은 새 테이블에 쌓이므로 바깥 전파와 안 섞인다.
|
|
- **그래서 게이트는 언제나 자기(와 그 배치)를 출처로 낸다** — 그냥
|
|
통과시킬 때도 상류 출처를 그대로 넘기지 않는다. 사용자: *"후행 노드들은 한개가 지연된거로
|
|
생각이 될 수 있겠지만, 사실 여기서 지연과 비지연을 구분할 이유가
|
|
없습니다."* 하류가 보는 차이는 집합의 원소가 하나냐 여럿이냐뿐이고 판정
|
|
규칙은 완전히 같다.
|
|
- 하류가 **평범한 노드**면, 출처가 `GateNode`일 때 그 집합을 순회하며 각
|
|
소스에 평소 규칙(1~3)을 적용하고, 하나라도 걸리면 **받은 출처를 그대로**
|
|
더 아래로 넘긴다(`state-epoch-plan.md` §2).
|
|
- **⭐ [2026-08-21 신설] 하류가 또 다른 게이트면 — 받은 집합을 풀어
|
|
자기 `withheld`에 합친다.** 게이트가 게이트 emit을 받는 경우가 정의돼
|
|
있지 않던 구멍이었다(사용자 발견). **출처를 그대로 넘기면 안 된다** —
|
|
상류 게이트는 자기 전파가 끝나자마자 `table.clear` 하므로, 하류 게이트가
|
|
유보했다가 나중에 풀면 그때는 **빈 집합**을 내보내게 되어 변경이 통째로
|
|
증발한다. 그래서 수신 시점에 **풀어서 옮겨 담아야** 한다:
|
|
```
|
|
-- 출처가 Source면 그 하나를, 게이트 배치면 그 배치 전부를 편다
|
|
for source in unfold(origin) do
|
|
self._withheld[source] = true
|
|
end
|
|
-- 그 다음 평소대로 정책 실행
|
|
```
|
|
게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느 층이
|
|
먼저 풀리든 정보가 안 샌다.
|
|
- **게이트의 `sourceEmitMap`은 수신 때가 아니라 실제로 전파할 때** 갱신한다
|
|
(집합 전체에 대해 한꺼번에). 그래야 "내가 하류로 던진 에포크"라는 맵의
|
|
뜻이 게이트에서도 참이 된다 — 유보 중 같은 에포크가 다른 경로로 또 오면
|
|
규칙 2로 걸려 정책을 한 번 더 태우는데, 이미 집합에 있으므로 무해하다.
|
|
- **⭐ [2026-08-21 `/code-review high`] emit 없이 푸는 경로는 집합을
|
|
*버려야* 한다.** `blocker:OffWithoutEmit()`은 정의상 "밀린 전파를 버리며
|
|
끈다"(`base/blocker-plan.md`)이므로, 그 경로도 **`withheld`를 비운다**
|
|
(전파는 안 하고 새 테이블로 스왑). 안 그러면 `Dispatch.drive`의 배치
|
|
게이팅이 매 프레임 `On()` → … → `OffWithoutEmit()`을 도는 동안 집합이
|
|
**단조 증가**하고, 나중에 아무 소스나 한 번 통과하는 순간 **버리기로 했던
|
|
옛 소스들이 같이 실려 나가** 하류가 폐기된 통지로 무효화된다.
|
|
- 그렇게 비우고 나면 하류의 `sourceEmitMap`은 뒤에 남지만, 그 소스의 다음
|
|
진짜 emit이 규칙 1/2로 걸려 **스스로 낫는다** — 별도 조치 불필요.
|
|
|
|
**⭐ 그래서 `setup` 시그니처는 안 바뀐다.** 집합을 채우는 건 정책이 아니라
|
|
**노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 단순화).
|
|
정책은 소스를 몰라도 되고, `Throttle`처럼 나중에 타이머에서 `emit()`을
|
|
부르는 경우도 그대로 동작한다 — 그때 쌓여 있던 집합이 그대로 나간다.
|
|
|
|
5. **생명주기.** 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 —
|
|
지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적,
|
|
`debounce-throttle-plan.md` 5-4). `Gate` 자체에 `Flush`/`Cancel` 같은 표면을
|
|
둘지, 그건 정책(Debounce)만의 것으로 둘지.
|
|
6. **재진입.** `Blocker`의 "재진입 의도적 미지원"(`blocker-plan.md`)이 `Gate`
|
|
레벨의 계약으로 올라가는지 — 즉 `onUpstreamEmit` 안에서 같은 게이트의
|
|
`emit()`을 재귀적으로 부르는 경우.
|
|
7. **⭐ 소비자가 하나 더 있다 — `Effect(fn, ...deps)`의 최초 1회 억제.**
|
|
2026-08-21 5라운드 `C-6`에서 확정된 다중 의존성 `Effect`는, 의존성마다 구독을
|
|
걸면 각 구독의 "등록 즉시 1회 실행"이 N번 발화하므로 **설치 구간 동안 발화를
|
|
눌러뒀다가 마지막에 한 번만 실행**해야 한다(`base/effect-plan.md`의 그 절).
|
|
즉 `Gate`(또는 `Blocker`의 직접 사용)가 **"설치 구간을 감싸 최초 발화를 한
|
|
번으로 접는" 용례까지 커버해야** 한다 — 설계할 때 이 소비자를 같이 볼 것.
|
|
8. **⭐ [2026-08-21 신설, `/code-review high`] 소스 없는 emit — 빈 배치를
|
|
어떻게 다루나.** `state:Gate`는 공개 표면이므로 유저랜드 정책이 상류 신호와
|
|
무관하게 `emit()`을 부를 수 있고, 위 3번 항목도 *"이 `emit`은 setup 밖으로
|
|
캡처해 **언제든** 부를 수 있다"*고 문서화한다. 그런데 지금 규칙대로면 그때
|
|
나가는 배치가 **비어 있어** 하류가 "전부 3번" 판정으로 **조용히 삼킨다.**
|
|
- 이건 아래 7번이 예고한 소비자에 직접 걸린다 — `Effect(fn, ...deps)`의
|
|
설치 구간 억제는 "어떤 Source 에포크와도 무관한 발화를 마지막에 한 번
|
|
내보내기"라, 지금 규칙으로는 성립하지 않는다.
|
|
- **에이전트 권고: 빈 배치 = 무조건 통지**(dedup 대상에서 제외). 명시적으로
|
|
부른 명령형 통지라 접을 근거가 없고, 규칙에 한 줄만 추가하면 된다.
|
|
대안은 `Effect` 쪽을 게이트 없이 자체 플래그로 해결하고 게이트에는 이
|
|
용례를 아예 안 주는 것 — 그러면 3번 항목의 "언제든 부를 수 있다"를
|
|
"상류 신호가 하나라도 쌓여 있을 때만 의미가 있다"로 좁혀 적어야 한다.
|
|
9. **M2 범위.** M2에 `Gate`만 넣고 `Blocker`는 M3에 그대로 둘지, 아니면
|
|
`Blocker`까지 같이 앞당길지. `Dispatch.drive`의 배치 등록이 실제로 쓰는 건
|
|
`blocker:On()`/`OffWithoutEmit()`/`IsOn()`이므로(배치 게이팅 절), **최소한
|
|
그 세 메서드가 도는 형태까지는 M2에 필요**하다.
|
|
|
|
## 관련 문서
|
|
|
|
- `base/blocker-plan.md` — 현행 `Blocker` 확정(이 문서가 일반화하려는 대상).
|
|
- `base/debounce-throttle-plan.md` — "공개 `Blocker` API 위엔 못 얹음" 절이
|
|
`Gate` 일반화를 처음 권고한 자리, 그리고 정책 쪽 설계 전량.
|
|
- `base/dispatch-core-plan.md` — "배치 등록을 안전하게 만드는 Blocker 게이팅"
|
|
절이 M2가 실제로 요구하는 표면.
|
|
- `ROADMAP.md` M2/M3.
|