Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions ai/journal/2026/08/AIJ-0030-sweep-write-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
id: AIJ-0030
date: 2026-08-20
kind: fix
phase: 3
plan: [3.6.1]
jira: CY-290
commits: [10eed66, e6a5a2f]
agent: claude-opus-5
confidence: high
promoted-to:
---

# 쓰기 순서가 계약이다 — 청소가 제거를 먼저 하고 있었다

## 무엇이 잘못돼 있었나

청소가 큐에서 **제거를 먼저** 하고 유예 기록을 나중에 했다. 대상이 많으면
기록 단계가 인자 한계에 걸리는데, 그때 제거는 이미 커밋된 뒤다.

```
3999명 → 정상. queue 3999→0, grace 3999
4000명 → ERR too many results to unpack
queue 4000→0, grace 0 ← 그 인원이 흔적 없이 사라진다
```

호출부는 에러만 받고 **사라진 사실조차 모른다.** 반환값이 없기 때문이다.

계획서가 이 청소를 Lua 로 둔 이유가 *"제거와 유예 이동이 나뉘면 자리 유실"*
인데, **현재 순서가 그 상황을 그대로 만들고 있었다.** 원자성은 확보했는데
순서를 안 봐서, 막으려던 것이 스크립트 안에서 일어났다.

## 왜 기록이 먼저여야 하나

인자가 쌍이라 기록 쪽이 한계에 **먼저** 걸린다. 걸리는 순간 큐는 아직
그대로고, 실패해도 남는 것은 "아직 안 빠진 사람" 이다 — 다음 틱에 다시
처리된다. **무해한 실패**로 바뀐다.

인원 상한을 둬도(CY-278) 이 순서가 필요하다. 기록 단계는 메모리 부족이나
자료형 오류로도 실패할 수 있고, 그때마다 같은 유실이 난다.

## 덤으로 치명 하나가 같이 닫혔다

메모리 상한에서 쓰기 거부가 **비대칭**이다. 실측:

| 명령 | 상한 도달 시 |
|---|---|
| `ZADD` (등록·하트비트) | `OOM command not allowed` |
| `ZREM` (청소) | **통과한다** |
| `SET NX PX` (리더 획득) | `OOM command not allowed` |
| `PEXPIRE` (리더 연장) | 통과한다 |

레디스는 스크립트가 **이미 쓰기를 한 뒤에는** OOM 으로 중단하지 않는다.
제거가 먼저면 청소는 그대로 완주하고, 아무도 하트비트를 못 하는 동안
**앞에서부터 매 틱 큐를 지운다.** 메모리를 줄이는 정상 경로(입장 확정)도
하트비트 쓰기에서 먼저 막혀 **자력 회복이 안 된다.**

기록(`HSET`)은 거부 대상이라, 순서를 뒤집으면 청소가 **첫 쓰기에서 통째로
막힌다.** 파괴 경로가 사라진다 (CY-295).

## 시험도 같은 한계에 걸려 있었다

큐를 채우는 픽스처가 한 번에 넣다가 **같은 인자 한계에 먼저 걸려**, 검증하려던
자리에 닿기도 전에 시험이 죽었다. 나눠 넣도록 고쳤다.

이게 왜 안 드러났나 — 기존 청소 시험이 도는 규모가 **최대 50명, 검사 범위
10** 이다. 실패는 4000명에서 난다. **운영 규모가 시험 규모의 400배**다.

## 확신이 낮은 부분

- **4000 은 이 배포판의 값이다.** Lua 의 인자 한계라 판이 바뀌면 달라진다.
시험이 상수로 8000/2 를 쓰고 있어서, 한계가 바뀌면 시험이 먼저 깨진다 —
깨지는 편이 조용히 통과하는 것보다 낫다.
- **OOM 조합은 카오스 시나리오에 없다.** 메모리 상한 도달은 설계가 스스로
예상하는 상태인데(20,000 대기 × 쿠폰 수) 시나리오 목록에 빠져 있다.

## 검증

- 되돌리면 실패한다 (자기검증)
- 3999 정상 / 4000 에서 큐가 그대로 남는 것까지 단언
- 단위·통합·카오스 전 계층 통과

## 다음 사람에게

**Lua 로 묶었다고 원자적인 것이 아니다.** 롤백이 없으므로 실패 지점 앞의
쓰기는 남는다. 그래서 **무엇을 먼저 쓰는지가 계약**이다 — 실패했을 때 남는
상태가 무해한 쪽을 앞에 둔다.
1 change: 1 addition & 0 deletions ai/journal/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

| ID | 날짜 | 종류 | 제목 | 확신 | 승격 |
|---|---|---|---|---|---|
| [AIJ-0030](2026/08/AIJ-0030-sweep-write-order.md) | 2026-08-20 | fix | 쓰기 순서가 계약이다 — 청소가 제거를 먼저 하고 있었다 | high | — |
| [AIJ-0027](2026/08/AIJ-0027-chaos-fixtures.md) | 2026-08-20 | implement | 장애 주입 픽스처 — 게이트는 선언한 페이즈에서 판정할 수 있어야 한다 | medium | — |
| [AIJ-0028](2026/08/AIJ-0028-snapshot-holder.md) | 2026-08-20 | implement | 낡음이 두 종류인 이유 — 섞으면 100% 장애다 | high | — |
| [AIJ-0026](2026/08/AIJ-0026-dependency-refresh.md) | 2026-08-20 | decision | 버전은 실측한다 — 검색 색인은 최신을 모른다 | high | — |
Expand Down
13 changes: 12 additions & 1 deletion src/main/resources/redis/sweep.lua
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,19 @@ if #front > 0 then
end

if #gone > 0 then
redis.call('ZREM', KEYS[1], unpack(gone))
-- **기록이 먼저다.** 제거를 먼저 하면 그 뒤가 터졌을 때 자리도 잃고
-- 재방문자로도 식별 안 되는 사람이 남는다 — 이 청소를 Lua 로 둔
-- 이유가 정확히 그것을 막는 것이다. 기록을 먼저 하면 실패 시 남는
-- 것이 "아직 안 빠진 사람" 이라 다음 틱에 다시 처리된다.
--
-- 인자가 쌍이라 records 가 gone 의 두 배다. unpack 한계에 이쪽이
-- 먼저 걸리므로, 걸리는 순간 큐는 아직 그대로다.
--
-- **메모리 상한에서도 이 순서가 산다.** HSET 은 거부 대상이라
-- 첫 쓰기에서 통째로 막히는데, ZREM 은 거부 대상이 아니라 먼저
-- 두면 아무도 하트비트를 못 하는 동안 큐만 계속 지운다.
redis.call('HSET', KEYS[2], unpack(records))
redis.call('ZREM', KEYS[1], unpack(gone))
swept = #gone
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.kafkick.waiting.adapter.redis;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import io.lettuce.core.RedisClient;
import io.lettuce.core.ScriptOutputType;
import io.lettuce.core.api.StatefulRedisConnection;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

/**
* 청소가 실패해도 <b>자리와 기록이 함께 움직인다</b>.
*
* <p>제거를 먼저 하면 그 뒤 단계가 터졌을 때 <b>자리도 잃고 재방문자로도
* 식별 안 되는 사람</b>이 남는다 — 이 스크립트를 Lua 로 둔 이유가 정확히
* 그것을 막는 것이다. 기록을 먼저 하면 실패 시 남는 것은 "아직 안 빠진
* 사람" 이라 다음 틱에 다시 처리된다.
*/
@Tag("chaos")
class SweepWriteOrderTest {

/** {@code unpack} 인자 한계. 기록은 쌍이라 이 절반에서 먼저 걸린다. */
private static final int UNPACK_LIMIT = 8000;

private static final String COUPON = "order";
private static final String QUEUE = RedisKeys.queue(COUPON, 1, 0);
private static final String GRACE = RedisKeys.grace(COUPON, 1, 0);
private static final String ALIVE = RedisKeys.alive(COUPON, 1, 0);

private RedisClient client;
private StatefulRedisConnection<String, String> connection;

@BeforeEach
void 준비() {
client = RedisClient.create("redis://%s:%d".formatted(
RedisContainerSupport.REDIS.getHost(),
RedisContainerSupport.REDIS.getFirstMappedPort()));
connection = client.connect();
connection.sync().del(QUEUE, GRACE, ALIVE);
}

@AfterEach
void 정리() {
connection.sync().del(QUEUE, GRACE, ALIVE);
connection.close();
client.shutdown();
}

/**
* 전원이 이탈한 큐를 만든다 — 생존 신호를 아예 안 넣는다.
*
* <p><b>나눠 넣는다.</b> 한 번에 넣으면 픽스처가 같은 인자 한계에 먼저
* 걸려, 검증하려던 자리에 닿기도 전에 시험이 죽는다.
*/
private void 이탈자를_채운다(int count) {
int batch = 1000;
for (int start = 0; start < count; start += batch) {
List<String> args = new ArrayList<>();
for (int i = start; i < Math.min(start + batch, count); i++) {
args.add(String.valueOf(i));
args.add("m" + i);
}
connection.sync().eval(
"redis.call('ZADD', KEYS[1], unpack(ARGV)) return 1",
ScriptOutputType.INTEGER, new String[] {QUEUE},
args.toArray(String[]::new));
}
}

private Object 청소한다(int limit) {
return connection.sync().eval(LuaScripts.of("sweep.lua"), ScriptOutputType.MULTI,
new String[] {QUEUE, GRACE, ALIVE},
String.valueOf(limit), "1800000000", "300", "50", "0");
}

@Test
@DisplayName("한_번에_못_담는_인원이면_아무도_빠지지_않는다")
void 한_번에_못_담는_인원이면_아무도_빠지지_않는다() {
// 제거가 먼저면 여기서 큐만 비고 기록이 0 이 된다 — 그 인원이
// 흔적 없이 사라지고 호출부는 사라진 사실조차 모른다.
int 인원 = UNPACK_LIMIT / 2;
이탈자를_채운다(인원);

assertThatThrownBy(() -> 청소한다(인원))
.hasMessageContaining("unpack");

// **수만 세면 다른 사람이 같은 수로 남아도 통과한다.** 자리는 순서가
// 곧 의미라, 누가 어느 순서로 남았는지까지 본다.
List<String> 남은사람 = connection.sync().zrange(QUEUE, 0, -1);
List<String> 원래순서 = new ArrayList<>();
for (int i = 0; i < 인원; i++) {
원래순서.add("m" + i);
}
assertThat(남은사람)
.withFailMessage("실패했는데 큐가 바뀌었다 — 자리도 기록도 없이 증발했다")
.containsExactlyElementsOf(원래순서);
assertThat(connection.sync().zscore(QUEUE, "m0")).isZero();
assertThat(connection.sync().hlen(GRACE)).isZero();
}

@Test
@DisplayName("담을_수_있는_인원은_자리와_기록이_함께_움직인다")
void 담을_수_있는_인원은_자리와_기록이_함께_움직인다() {
int 인원 = UNPACK_LIMIT / 2 - 1;
이탈자를_채운다(인원);

청소한다(인원);

assertThat(connection.sync().zcard(QUEUE)).isZero();
assertThat(connection.sync().hlen(GRACE)).isEqualTo(인원);
}
}
Loading