468 lines
70 KiB
Markdown
468 lines
70 KiB
Markdown
---
|
||
title: branch / feature-redis-atomic-program-catalog-contract
|
||
source_type: branch-note
|
||
status: raw
|
||
branch: feature-redis-atomic-program-catalog-contract
|
||
parent_branch:
|
||
related_projects: [ca-skeleton]
|
||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||
tags: [branch, ca-skeleton, redis, lua, atomicity, script-catalog]
|
||
created: 2026-07-28
|
||
target_merge:
|
||
status_label: in-progress
|
||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-062
|
||
kind: project-work-item
|
||
project: ca-skeleton-operational-contract
|
||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-062
|
||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-PROGRAM-001@1]
|
||
refines: []
|
||
overrides: []
|
||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-061]
|
||
imports: []
|
||
delegates: []
|
||
accepts_delegations: []
|
||
contract_packet: 1
|
||
---
|
||
|
||
# branch: feature-redis-atomic-program-catalog-contract
|
||
|
||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-redis-atomic-program-catalog-contract` 로 채운다.
|
||
|
||
<!-- section-id: branch-parent -->
|
||
## 부모 (필수)
|
||
|
||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||
|
||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 기술 런타임 (Tier T). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-062` 의 실행 단위다.
|
||
|
||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||
|
||
- [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]]
|
||
- [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]]
|
||
- [[raw/branch-notes/feature-fenced-lock-coordination-contract]]
|
||
|
||
<!-- section-id: branch-contract-packet -->
|
||
## 브랜치 계약 패킷
|
||
|
||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||
|
||
- **생성 시 프로젝트 개정**: `1`
|
||
- **패킷 스키마**: `contract_packet: 1`
|
||
- **완료 조건**: program descriptor·golden vector·NOSCRIPT 재적재 test 와 unsafe recipe 경합 재현 test 가 통과한다
|
||
|
||
<!-- section-id: inherited-project-decisions -->
|
||
### 상속한 프로젝트 결정
|
||
|
||
| Decision Ref | Project Summary | Branch Application | Source |
|
||
|---|---|---|---|
|
||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-PROGRAM-001@1` | 다단계 read-decide-write는 버전 있는 atomic program으로만 제공하고 각 helper는 unsafe recipe와 안전한 대체를 쌍으로 문서화하며 경합 test로 증명한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||
|
||
<!-- section-id: branch-local-decisions -->
|
||
### 브랜치 지역 결정
|
||
|
||
> 상세 근거와 선택 조건은 아래 결정-근거 매핑 표의 동일 D-row 가 소유한다. 여기에는 1줄 요약만 둔다.
|
||
|
||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||
|---|---|---|---|---|
|
||
| D1 | atomic program 본문의 SSOT 는 `.lua` classpath resource 파일이고, 실행 메타데이터는 별도 descriptor 가 소유한다 | `refines DEC-…-REDIS-PROGRAM-001@1` | `spring-data-redis-scripting-execution.md#SDR-SCRIPT-C6`·`#SDR-SCRIPT-C5` | `proposed` |
|
||
| D2 | EVALSHA 우선 + `NOSCRIPT` 를 받은 그때만 EVAL 로 1회 폴백. Spring Data Redis 에 위임하지 않고 Lettuce 위에 직접 구현한다 | `local` | **as-built** `RedisLuaProgramExecutor.execute()`, `redis-eval-scripting-atomicity.md#REDIS-EVAL-C3` | `actually-implemented` |
|
||
| D3 | pipeline 경로에서는 EVALSHA 를 쓰지 않고 plain EVAL 로 되돌린다 | `local` | `redis-eval-scripting-atomicity.md#REDIS-EVAL-C4` | `proposed` |
|
||
| D4 | bounded execution 은 descriptor 선언 + 빌드타임 정적 검사를 1차 강제로 두고, 서버 실행시간 설정·kill 런북은 상시 병행하는 사후 안전망으로 분리한다 | `refines DEC-…-REDIS-PROGRAM-001@1` | `redis-eval-scripting-atomicity.md#REDIS-EVAL-C1`·`#REDIS-EVAL-C6`, `redis-functions-library-deployment.md#REDIS-FN-C4` | `proposed` |
|
||
| D5 | 모든 key 는 `KEYS` 로 선언하고, 미선언 key·프로그램 생성 key 접근을 program 작성 규칙으로 금지한다 | `local` | `redis-eval-scripting-atomicity.md#REDIS-EVAL-C5`, `redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C5` | `proposed` |
|
||
| D6 | 배포 모드 기본값은 EVAL script 이고 Redis Functions 는 3조건 동시 충족 시에만 opt-in | `local` | `redis-functions-library-deployment.md#REDIS-FN-C1`·`#REDIS-FN-C2`·`#REDIS-FN-C6`, `redis-eval-scripting-atomicity.md#REDIS-EVAL-C2` | `proposed` |
|
||
| D7 | unsafe recipe ↔ 대체 쌍은 **읽은 값 기반 분기 유무**로 갈라 등재한다 (분기 없음 → `MULTI`/`EXEC`, 분기 있음 → atomic program) | `refines DEC-…-REDIS-PROGRAM-001@1` | `redis-incr-rate-limiter-pattern.md#REDIS-INCR-C2`·`#REDIS-INCR-C3`·`#REDIS-INCR-C4` | `proposed` |
|
||
| D8 | typed facade 는 semantics-free — program 식별자·KEYS·ARGV·반환 타입만 다루고 능력 의미(rate limit·lock·idempotency)를 알지 않는다 | `local` | `spring-data-redis-scripting-execution.md#SDR-SCRIPT-C3`·`#SDR-SCRIPT-C4` + 소비 branch 4건의 out-of-scope 선언 | `proposed` |
|
||
| D9 | 경합 증명은 2층 분리 — unsafe recipe 의 race 는 fake + 결정론적 인터리빙(focused), atomic 대체의 원자성은 실제 Redis 통합 test | `local` | `test-taxonomy-testcontainers-official.md#TC-OFFICIAL-C3`·`#TC-OFFICIAL-C5`, `redis-eval-scripting-atomicity.md#REDIS-EVAL-C1` | `proposed` |
|
||
| D10 | program 실행 실패는 backend 실패와 program 결함으로 등급을 나눈다 (결함은 miss 로 위장 금지) | `local` (hub §11 매트릭스 consume) | `redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C1`·`#REDIS-LUAAPI-C2` | `proposed` |
|
||
| D11 | program 실행에 bounded cardinality metric 1개 + 실행당 trace span 1개를 짝지어 붙이고, key·ARGV 는 절대 tag/attribute 로 넣지 않는다 | `local` (`feature-metrics-alerting-contract` D2·D8 consume) | [[raw/branch-notes/feature-metrics-alerting-contract]] `D2`·`D8` + `metrics.yaml` 의 `lock.acquisition` 선례 | `proposed` |
|
||
|
||
<!-- section-id: declared-overrides -->
|
||
### 선언한 예외
|
||
|
||
| Override ID | Overrides | Reason | Approval | Status |
|
||
|---|---|---|---|---|
|
||
|
||
<!-- GENERATED: project-contract-imports:start -->
|
||
## 가져온 프로젝트 계약
|
||
|
||
| Ref | Owner | 요약 | Branch 적용 |
|
||
|---|---|---|---|
|
||
<!-- GENERATED: project-contract-imports:end -->
|
||
|
||
<!-- section-id: branch-goal -->
|
||
## 목표
|
||
|
||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-062` 의 완료 조건을 구현한다: program descriptor·golden vector·NOSCRIPT 재적재 test 와 unsafe recipe 경합 재현 test 가 통과한다
|
||
|
||
- 이슈:
|
||
- PR:
|
||
|
||
<!-- section-id: branch-scope -->
|
||
## 범위
|
||
|
||
### 포함 범위
|
||
|
||
- atomic program 카탈로그와 `ScriptDescriptor` 필수 필드
|
||
- EVALSHA 실행 + NOSCRIPT 안전 재적재
|
||
- bounded execution 규칙 (O(1)/유한, KEYS 선언, 무한 루프·대량 스캔 금지)
|
||
- unsafe multi-command recipe ↔ atomic 대체의 쌍 문서화와 경합 test 의무
|
||
- Redis Functions 배포 모드의 opt-in 조건
|
||
- golden vector 와 typed facade
|
||
|
||
### 제외 범위
|
||
|
||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||
|
||
- 능력별 상태 프로그램의 의미 — 각 능력 branch 소유
|
||
- Redis 연결·키·코덱 — #061 소유
|
||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||
- project decision registry 변경 — owner 는 project-note
|
||
|
||
## 근거 (필수, 최소 1개+)
|
||
|
||
| Source | 정당화하는 결정 |
|
||
|---|---|
|
||
| [[raw/official-docs/redis-eval-scripting-atomicity]] | 다단계 read-decide-write 는 EVALSHA 로 실행하는 버전 있는 atomic program 으로만 제공하고, NOSCRIPT 시 안전 재적재하며, bounded execution(유한 실행) 규칙을 둔다 — 스크립트 원자성(블로킹 실행)·EVALSHA/NOSCRIPT 복구·KEYS 선언 규칙·SCRIPT KILL 근거 |
|
||
| [[raw/official-docs/spring-data-redis-scripting-execution]] | ⚠️ **채택하지 않은 대안의 근거** — 초판은 이 문서를 D1·D2 의 기반으로 삼았으나 as-built 에 `spring-data-redis` 의존성이 없어 폐기했다. 현재는 "프레임워크에 위임했다면 어땠을지" 의 비교 기준(`#SDR-SCRIPT-C1` 의 evalsha→eval 자동 폴백)으로만 남는다. `#SDR-SCRIPT-C6`(script 를 classpath resource 로 두는 패턴)만 D1 에서 여전히 유효 |
|
||
| [[raw/official-docs/redis-lua-api-determinism-replication]] | call/pcall 오류 전파 차이, typed error/status reply(`redis.error_reply`/`redis.status_reply`), effects replication(Redis 7.0 기준 유일 모드) + `redis.replicate_commands()` 폐기 이력, KEYS 선언 강제, sandbox 제약(전역 변수 금지·`require` 비활성화)의 공식 근거 |
|
||
| [[raw/official-docs/redis-functions-library-deployment]] | Redis Functions(FUNCTION LOAD/LIST, Shebang 선언, `no-writes` flag, Redis 7.0 버전 요구, cluster 수동 배포)의 opt-in 조건을 무엇으로 삼을지의 공식 근거 |
|
||
| [[raw/official-docs/redis-incr-rate-limiter-pattern]] | unsafe multi-command recipe (`GET`→판단→`INCR`→조건부 `EXPIRE`) 의 race condition 을 공식 문서가 명시 — 각 atomic program helper 가 unsafe recipe 와 안전한 atomic 대체(MULTI/EXEC 또는 Lua/EVAL)를 쌍으로 문서화해야 한다는 결정의 근거 |
|
||
| [[raw/official-docs/redis-programmability-max-execution-time]] | bounded execution 규칙을 예방 계층(정적 검사)과 사후 대응 계층(서버 설정 + kill 런북)으로 분리해 상시 병행한다는 결정(D4)의 세부 근거 — 기본 5초 임계값·`busy-reply-threshold` 설정명, 시간 초과 시 자동 종료하지 않는 이유(atomicity 계약 보호), `BUSY` 에러 상태의 허용 명령(`SCRIPT KILL`/`FUNCTION KILL`/`SHUTDOWN NOSAVE`), 쓰기 수행 스크립트는 `SHUTDOWN NOSAVE` 만 허용됨을 공식 확인 |
|
||
|
||
| [[raw/official-docs/test-taxonomy-testcontainers-official]] (재사용 — owner 는 `feature-test-taxonomy-fixture-contract`) | D9 의 "atomic 원자성은 실제 Redis 로만 증명" — `#TC-OFFICIAL-C3`(mock/in-memory 없이 production 과 동일 type 서비스), `#TC-OFFICIAL-C5`(in-memory 는 production service 의 기능을 다 갖지 않음). ⚠️ 원문의 예시는 H2 이고 **Redis·Lua 를 직접 말하지 않는다** — "임베디드 Redis 는 Lua 를 온전히 못 돌린다" 는 이 claim 의 *유추*이지 인용이 아니다. D9 자체는 `#REDIS-EVAL-C1`+`#TC-OFFICIAL-C3` 로 닫힌다 |
|
||
|
||
**추가 수집 완료** (`busy-reply-threshold`/`BUSY`/`SHUTDOWN NOSAVE` bounded-execution 세부 설정값): `[[raw/official-docs/redis-programmability-max-execution-time]]` (`develop/programmability/` §Maximum execution time)로 닫혔다 — `REDIS-PROG-C1`~`C4`. **D4 의 Supporting Claims·Open Risk 와 §구현 가이드 3 의 (3)층 표, §엣지·실패·의존에 이미 반영 완료**(2026-07-28 동일 `/branch-spec` 실행). 구 파라미터명 `lua-time-limit` 과 `maxmemory` 상호작용은 이 자료에도 없어 미확인으로 남으며, 후자는 §Claims To Verify 에 등록했다.
|
||
|
||
**미수집 — 결정에 사용하지 않음**: LINE engineering 의 "Lua 채택 + EVALSHA 의도적 거부" 사례(§Audit `EVALSHA_COUNTEREXAMPLE`). D2 의 반례 후보이나 raw archive 전이므로 인용하지 않았다.
|
||
|
||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||
|
||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||
|
||
## TODO
|
||
|
||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||
|
||
- [x] `/branch-spec` 로 D-row(D1~D11)·§구현 가이드(9절) 작성 — 등급: `documented-only`
|
||
- [x] `.lua` 카탈로그 + descriptor + manifest 대조 test (D1) — 등급: `actually-implemented` (`RedisProgramCatalog`·`RedisProgramDescriptor`·`program-set.json`)
|
||
- [x] EVALSHA + NOSCRIPT EVAL 폴백 (D2) — 등급: `actually-implemented` (`RedisLuaProgramExecutor` + test 2건)
|
||
- [x] 런타임 admission — arity·바이트 상한·카탈로그 소유권 (D4 (i)층) — 등급: `actually-implemented` (`RedisLuaProgramExecutor.validate()`)
|
||
- [x] typed facade 2층 구조 (D8) — 등급: `actually-implemented` (`RedisAtomicPrimitives` + `RedisProgramExecutor` seam)
|
||
- [ ] **§1·§2·§6·§7 을 as-built 로 정정** — 초판 Spring Data Redis 전제 잔존 (§구현 가이드 preamble 표 참조) — 등급: `planned`
|
||
- [ ] **`2026-07-26-redis-production-capability-design.md`(6,700줄) 정독** 후 `UNSUPPORTED_IMPL_DECISION` 라벨 재판정 — 등급: `planned`
|
||
- [ ] **unsafe recipe 경합 재현 test** — WI-062 완료 조건 중 유일한 미구현분 (D9) — 등급: `planned`
|
||
- [ ] bounded execution 빌드타임 정적 검사 + denylist 시드 (D4 (ii)층) — 등급: `planned`
|
||
- [ ] unsafe recipe ↔ 대체 쌍 카탈로그 2행 등재 (D7) — 등급: `planned`
|
||
- [ ] program 실패 error code + metric·span registry 등재 제안 (D10·D11) — 등급: `planned`
|
||
- [ ] **선행 확인**: 배포 이미지의 실제 `redis_version` ≥ 7.2 — 등급: `needs-confirmation`
|
||
- [ ] **harness 정정 요청 전달**: `/branch-spec` 명령 문서의 ca-tmpl ground truth 경로 (§Audit `STALE_CLONE_ROOT_CAUSE`) — 등급: `needs-confirmation`
|
||
|
||
## 진행 중 메모
|
||
|
||
- 2026-07-28 — **본 branch 는 greenfield 가 아니다.** foundation 이 이미 구현돼 있다(`RedisProgramCatalog`·`RedisProgramDescriptor`·`RedisProgramExecutor`·`RedisLuaProgramExecutor`·`RedisAtomicPrimitives` + `.lua` 3개 + `program-set.json` + test 7개). 착수는 "신규 작성" 이 아니라 **"as-built 확장"** 으로 계획할 것.
|
||
- ⚠️ **초판의 3중 오판을 정정했다** — "코드 없음 / Redis 버전 미고정 / 설계문서 부재" 는 전부 낡은 clone(`workspace/ca-tmpl`)을 조회한 결과였다. 실제 ground truth 는 `desktop-server-git/clean-architecture-backend-template/` 다(§Audit `STALE_CLONE_ROOT_CAUSE`).
|
||
- SDK 는 `io.lettuce:lettuce-core` 단독이며 `spring-data-redis` 는 없다. Redis 최소 버전은 `program-set.json` 의 `minimumRedisVersion: "7.2"` 로 고정돼 있다.
|
||
- **다음 세션 우선 작업**: `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md`(6,700줄) 정독. 이 문서가 golden vector 형식·BUSY 관측·Functions 호환·버전 pin 을 직접 다루므로, 현재 `UNSUPPORTED_IMPL_DECISION` 으로 남긴 항목 상당수가 근거를 얻을 가능성이 높다.
|
||
|
||
## 결정 사항
|
||
|
||
- 2026-07-28: **atomic program 본문은 `.lua` classpath resource, 메타데이터는 별도 descriptor** (D1) / 이유: Spring 공식 예시가 `ResourceScriptSource`+`ClassPathResource` 를 표준 패턴으로 제시하고, SHA1 이 본문에서 자동 도출돼 "변경 시 버전 변경" 을 별도 규약 없이 만족 / 검토한 대안: Java 인라인 문자열 상수(LINE 프로덕션 선례 있음, 다만 미archive), Redis Functions library 파일, 빌드타임 코드생성 / 근거: [[raw/official-docs/spring-data-redis-scripting-execution]]
|
||
- 2026-07-28: **EVALSHA 우선 + `NOSCRIPT` 시에만 EVAL 1회 폴백을 Lettuce 위에 직접 구현** (D2, 등급 `actually-implemented`) / 이유: as-built 가 이미 이 형태이고 `spring-data-redis` 의존성이 없다 / 검토한 대안: Spring Data Redis `ScriptExecutor` 위임 — SDK 를 새로 들여야 하고 #061 이 raw Lettuce 로 기울어 채택하지 않음 / 위험: 공식 절차(`SCRIPT LOAD`+재시도)와 경로가 달라 캐시 재적재를 코드가 관측하지 않음 — §Claims To Verify 등록 / 근거: [[raw/official-docs/redis-eval-scripting-atomicity]] + as-built `RedisLuaProgramExecutor`
|
||
- 2026-07-28: **bounded execution 은 예방(정적)과 사후 대응(서버 설정)을 분리해 둘 다 채택** (D4) / 이유: 쓰기를 수행한 program 은 `SCRIPT KILL` 로 죽일 수 없어(공식) 예방이 필수이고, 데이터 규모에서만 드러나는 초과는 정적 검사로 원천 재현 불가라 사후 층이 필수 / 검토한 대안: 서버 설정만, 리뷰 체크리스트만 / 근거: [[raw/official-docs/redis-eval-scripting-atomicity]]
|
||
- 2026-07-28: **배포 모드 기본은 EVAL script, Functions 는 3조건 opt-in** (D6) / 이유: Functions 는 Redis 7.0+ 필요한데 대상 버전이 확정돼 있지 않다(§Audit `NO_VERSION_PIN`) / 검토한 대안: Functions 를 SSOT 로 채택 — SHA 기반 버전 식별이 성립하지 않아 보류 / 근거: [[raw/official-docs/redis-functions-library-deployment]]
|
||
- 2026-07-28: **unsafe 쌍은 분기 유무로 분리 등재** (D7) / 이유: 공식이 분기 없는 recipe 에는 `MULTI`/`EXEC`, 분기 있는 recipe 에는 Lua 를 **서로 다르게** 권고한다. 한 항목으로 뭉치면 이 구분이 사라진다 / 근거: [[raw/official-docs/redis-incr-rate-limiter-pattern]]
|
||
- 2026-07-28: **경합 증명을 focused / integration 2층으로 분리** (D9) / 이유: race 는 클라이언트 측 순서 문제라 fake+barrier 로 결정론적 재현이 가능하지만, 원자성은 서버가 보장하는 성질이라 실제 Redis 없이는 증명 불가 / 제약: focused 레벨 Testcontainers 금지는 [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] 가 ArchUnit 으로 이미 강제 / 근거: [[raw/official-docs/redis-eval-scripting-atomicity]] · [[raw/official-docs/test-taxonomy-testcontainers-official]]
|
||
|
||
<!-- section-id: decision-evidence -->
|
||
## Decision Evidence Map / 결정-근거 매핑
|
||
|
||
> 2026-07-28 `/branch-spec` 조사(Redis 공식 6건 + Spring Data Redis 1건 + 기존 Testcontainers raw 재사용)로 채웠다.
|
||
> **본 branch 는 greenfield 가 아니다 — foundation 이 이미 구현돼 있다.** 실제 ground truth 는 `/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/` 이며(§Audit `GROUND_TRUTH_PATH_DRIFT`), `adapter/outbound/cache-redis` 에 `RedisProgramCatalog`·`RedisProgramDescriptor`·`RedisProgramExecutor`·`RedisLuaProgramExecutor`·`RedisProgramId` + `.lua` 3개 + `program-set.json` manifest + 전용 test 7개가 존재한다(등급 `actually-implemented`).
|
||
> 따라서 아래 D-row 는 **as-built 를 서술**하거나(등급 표기) **as-built 를 확장**하는 결정이다. 초기 작성 시 `planned` 로 적었던 것을 코드 대조 후 정정했다.
|
||
|
||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||
|---|---|---|---|---|---|
|
||
| D1 | atomic program 본문의 SSOT 는 `.lua` classpath resource, 식별·서명은 **닫힌 enum 카탈로그**(`RedisProgramId` → `RedisProgramDescriptor`)가 소유하고, 외부 노출은 `program-set.json` manifest 가 미러링한다 | 항상. D6 이 Functions 모드로 뒤집히면 SSOT 가 library 파일(`#!lua name=…`)로 이동한다 — `redis-functions-library-deployment.md#REDIS-FN-C3` | `raw/official-docs/spring-data-redis-scripting-execution.md#SDR-SCRIPT-C6`(script 본문을 classpath resource 로 두는 패턴) + **as-built**: `RedisProgramId`(3 program, `<name>-v1` + `redis/scripts/*.lua`), `RedisProgramCatalog.foundation()`, `redis/program-set.json` | `official-vendor-doc` + `actually-implemented` | as-built 는 **버전을 id 문자열(`-v1`)에 넣고 SHA-256 을 source digest 로 따로 보관**한다 — 내가 처음 적은 "SHA1 이 곧 버전" 은 as-built 와 다르다(SHA-1 은 EVALSHA 전송용으로만 쓰임). manifest 와 카탈로그의 drift 는 unit test 가 잡지만, `.lua` 와 descriptor 의 **arity** drift 는 실행 시점에만 드러난다 |
|
||
| D2 | EVALSHA 우선 + `NOSCRIPT` 를 받은 **그때만** EVAL 로 1회 폴백. Spring Data Redis 에 위임하지 않고 Lettuce 위에 직접 구현한다 | 항상 — as-built 가 이미 이 형태다. Spring Data Redis 를 도입하면 `ScriptExecutor` 위임으로 대체 가능하나, #061 이 raw Lettuce 유지로 기운 상태라 전환 근거가 없다 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C3`(NOSCRIPT 복구 절차) + **as-built**: `RedisLuaProgramExecutor.execute()` 가 `commands.evalSha(...)` → `catch RedisNoScriptException` → `commands.eval(...)`. test `fallsBackToEvalOnlyWhenEvalShaReportsNoScript`·`doesNotEvalAgainWhenCachedScriptExecutes` | `official-vendor-doc` + `actually-implemented` | as-built 는 `SCRIPT LOAD` 를 쓰지 않고 **EVAL 직접 실행**으로 복구한다. `#REDIS-EVAL-C3` 이 서술한 "`SCRIPT LOAD` 후 EVALSHA 재시도" 와 다른 경로이며, 결과적으로 캐시 재적재 여부를 코드가 관측하지 않는다(EVAL 이 부수적으로 캐시에 넣음). 기능상 동등하나 공식이 명시한 절차는 아님 |
|
||
| D3 | pipeline 경로에서는 EVALSHA 를 쓰지 않고 plain EVAL 로 되돌린다 | program 을 pipeline 안에서 실행할 때. 단건 실행이면 D2 의 EVALSHA 경로 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C4`(pipeline 안에서는 NOSCRIPT 가 반환돼도 handle 할 수 없으므로 plain EVAL 로 revert 해야 함) | `official-vendor-doc` | 근거의 구속력이 `should`(권고)이지 `MUST` 가 아니다(`#REDIS-EVAL-C4` 의 does-not-prove). 또 Spring 의 `ScriptExecutor` 가 pipeline 안에서 이 revert 를 자동으로 하는지는 `#SDR-SCRIPT-C1` 이 말하지 않는다 |
|
||
| D4 | bounded execution 은 (i) descriptor 선언 + **런타임 admission**(as-built) + (ii) 빌드타임 정적 검사(신규)를 1차 강제로, (iii) 서버 실행시간 설정 + kill 런북을 상시 병행 안전망으로 **분리** | (i)은 arity·바이트 상한·카탈로그 소유권처럼 **호출 시점에 판정 가능한** 위반에 적용(as-built 가 이미 강제). (ii)는 `.lua` 텍스트의 정적 위반(비유한 명령 토큰·무한 루프 리터럴)에 적용 — **as-built 에 없음**. 운영 **데이터 규모**에서만 드러나는 초과는 (i)(ii)로 원천 재현 불가하므로 (iii)이 담당 — (iii)은 선택이 아니라 필수 병행 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C1`(실행 중 모든 서버 활동 블로킹), `#REDIS-EVAL-C6`(`SCRIPT KILL` 은 dataset 을 수정하지 **않은** 스크립트에만 가능), `raw/official-docs/redis-functions-library-deployment.md#REDIS-FN-C4`(Functions 도 동일 블로킹), `raw/official-docs/redis-programmability-max-execution-time.md#REDIS-PROG-C1`~`#REDIS-PROG-C4`(기본 5초 `busy-reply-threshold` · 자동 종료 안 함 · `BUSY` 허용 명령 · 쓰기 후엔 `SHUTDOWN NOSAVE` 뿐) | `official-vendor-doc` ×3 | **쓰기를 수행한 program 의 유일한 종료 수단이 `SHUTDOWN NOSAVE`(인스턴스 강제 정지)** 라는 것이 강제가 필요한 진짜 이유다(`#REDIS-PROG-C4`). 남은 미확인: 구 파라미터명 `lua-time-limit` 과의 관계, `maxmemory` 근접 시 script abort 동작 — 두 자료 모두에 없다 |
|
||
| D5 | 모든 key 는 `KEYS` 입력 인자로 선언. 프로그램 생성 key·데이터 내용 기반 key 접근을 program 작성 규칙으로 금지 | 항상 — standalone·cluster 무관. cluster 에서는 추가로 같은 slot 으로 해싱되어야 multi-key 연산이 성립한다 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C5`, `raw/official-docs/redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C5` (양쪽 모두 "should only access keys given as input arguments" / "should never access programmatically-generated names") | `official-vendor-doc` ×2 (독립 페이지 2곳) | 미선언 key 를 실제로 접근했을 때의 **거부 동작·에러 코드가 공식에 없다**(`#REDIS-EVAL-C5` does-not-prove) — 즉 Redis 가 막아주지 않으므로 우리가 강제하지 않으면 조용히 통과한다. hash slot 라우팅 자체는 #061 소유 |
|
||
| D6 | 배포 모드 기본값은 **EVAL script**. Redis Functions 는 ① 대상 Redis 7.0+ 확정 ② NOSCRIPT 재적재 부담 제거가 실제 문제로 관측 ③ cluster 전 노드 수동 적재 운영을 수용 — 3조건 동시 충족 시에만 opt-in | 위 3조건 중 하나라도 미충족이면 EVAL script. 특히 **대상 Redis 버전이 확정되지 않은 현재 상태에서는 Functions 를 고를 수 없다** | `raw/official-docs/redis-functions-library-deployment.md#REDIS-FN-C1`(7.0+ 필요), `#REDIS-FN-C2`(Functions 는 AOF 영속+복제 / script 는 휘발성 캐시), `#REDIS-FN-C6`(cluster 전 노드 적재는 자동화되지 않음), `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C2`(캐시는 재시작·failover·`SCRIPT FLUSH` 시 소실) | `official-vendor-doc` | ca-tmpl 어디에도 **Redis 버전이 고정돼 있지 않다** — compose 에 redis 서비스 없음, SDK 의존성 없음(§Audit `NO_VERSION_PIN`). 조건 ①을 지금 판정할 수 없다. `#REDIS-FN-C2` 의 durability 가 `appendonly no` 배포에서도 성립하는지도 미서술 |
|
||
| D7 | unsafe recipe ↔ 안전한 대체 쌍은 **읽은 값 기반 분기 유무**로 갈라 등재한다 | 분기 없음(고정 키 카운터 등) → `MULTI`/`EXEC` 로 충분하며 **program 을 만들지 않는다**. 분기 있음(`IF value == 1` 류) → `MULTI`/`EXEC` 로 불충분하므로 atomic program 필수 | `raw/official-docs/redis-incr-rate-limiter-pattern.md#REDIS-INCR-C4`(공식이 두 변형에 서로 다른 대체를 권고), `#REDIS-INCR-C2`(rate limiter 2 에 race condition 존재를 공식이 명시), `#REDIS-INCR-C3`(실패 모드 = `EXPIRE` 유실 → key leak) | `official-vendor-doc` | 이 분기 기준을 잃고 "INCR+EXPIRE" 를 한 항목으로 뭉치면 `#REDIS-INCR-C4` 의 구분이 사라진다. 또 공식은 key leak 의 **정량적 심각도**를 말하지 않는다(정성 서술만) |
|
||
| D8 | typed facade 는 semantics-free — program 식별자·KEYS·ARGV·상태 문자열만 다루고 능력 의미를 알지 않는다. descriptor·catalog·executor 는 **package-private** 로 유지해 adapter 밖으로 새지 않는다 | 항상. 능력 의미(rate limit 판정·lock fencing·idempotency claim 결과)가 facade 에 새어들면 소비 branch 의 owner 경계를 침범한다 | **as-built**: `RedisProgramExecutor` javadoc — "Implementations may use Functions or EVALSHA, but application code must only depend on semantic ports and typed facades". 전 program 타입이 package-private. 반환은 `descriptor.statuses()` 에 속한 ASCII 상태 문자열 | `actually-implemented` | as-built 반환 타입은 `#SDR-SCRIPT-C4` 의 4종이 아니라 **상태 문자열 단일 형태**다(≤128 byte). 값을 함께 돌려줘야 하는 program(예: 남은 quota)이 생기면 이 형태로 부족하고, 그 확장 형식은 근거가 없다 |
|
||
| D9 | 경합 증명을 2층으로 분리 — unsafe recipe 의 race 는 fake + 결정론적 인터리빙으로 focused test, atomic 대체의 원자성은 **실제 Redis** 통합 test | 원자성 주장은 서버가 보장하는 성질이므로 fake 로 증명 불가 → 실제 Redis 필수. 반대로 unsafe recipe 의 인터리빙은 클라이언트 측 순서 문제이므로 fake 로 결정론적 재현이 가능하고, 그래야 focused test 의 "실제 네트워크 금지" 규칙을 지킨다 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C1`(원자성은 서버 보장), `raw/official-docs/test-taxonomy-testcontainers-official.md#TC-OFFICIAL-C3`(mock/in-memory 없이 production 과 동일 type 서비스로 test), `#TC-OFFICIAL-C5`(in-memory 는 production service 의 기능을 다 갖지 않음) | `official-vendor-doc` + `official-reference` | **WI-062 완료 조건의 미충족분이 정확히 여기다.** as-built 는 NOSCRIPT 재적재 test 와 golden-vector 성격 test 를 이미 갖췄지만(`fallsBackToEvalOnlyWhenEvalShaReportsNoScript`·`loadsEveryFoundationProgramWithAnExactDigestAndBoundedSignature`), **unsafe recipe 경합 재현 test 는 모듈에 존재하지 않는다**(`CountDownLatch`/`Thread` grep 0건). 반복 횟수·스레드 수의 공식 근거도 없다 |
|
||
| D11 | program 실행에 bounded cardinality metric 1개(`redis.program.executions.total`, tag = `program_id`+`outcome`)와 실행당 trace span 1개를 짝지어 붙이고, key·ARGV 는 tag/attribute 로 넣지 않는다 | 항상. `program_id` 를 tag 로 쓰는 것은 **카탈로그가 닫힌 enum 이라 cardinality 가 구조적으로 유한할 때만** 유효하다 — 런타임 등록으로 카탈로그가 열리면 이 tag 를 빼야 한다 | [[raw/branch-notes/feature-metrics-alerting-contract]] `D2`(Micrometer dot.case 명명)·`D8`(high-cardinality tag 금지 + bounded whitelist, Prometheus·Micrometer 공식 근거 보유) + `metrics.yaml` 의 `lock.acquisition` 선례("key 는 tag 로 넣지 않는다") + **as-built** 닫힌 `EnumMap` 카탈로그 | `project-registry-contract` (owner 는 metrics-alerting) + `actually-implemented`(카탈로그 유한성) | metric 이름·`outcome` 어휘·cardinality 상한은 공식 근거가 없다(§구현 가이드 9 에서 `UNSUPPORTED_IMPL_DECISION` 라벨). `metrics.yaml` 에 script/program row 가 **0건**이므로 전부 신규 제안이며 registry-governance 절차를 거쳐야 한다. **`BUSY` 상태는 서버 측 현상이라 이 클라이언트 metric 으로 직접 관측되지 않는다** — D4 (iii)층의 신호는 별도다 |
|
||
| D10 | program 실행 실패를 backend 실패와 program 결함으로 등급 분리. 결함은 해당 연산을 fail closed 하고 **miss 로 위장하지 않는다** | backend 연결·timeout 실패 → 기존 `CACHE_UNAVAILABLE`(`TRANSIENT_DEPENDENCY`) 선례를 따름. program 결함(Lua 런타임 오류·반환 계약 위반) → fail closed. 이 경계는 hub §11 매트릭스의 "cache codec·프로그래밍 결함은 fail closed, miss 로 위장 금지" 와 동일 규칙 | `raw/official-docs/redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C1`(`redis.call` 은 예외를 그대로 올려 스크립트 중단 / `redis.pcall` 은 절대 throw 하지 않고 error 테이블 반환), `#REDIS-LUAAPI-C2`(`redis.error_reply`/`redis.status_reply` 가 typed 반환의 공식 수단) | `official-vendor-doc` (등급 분리 규칙 자체는 hub §11 이 owner) | **신규 error code 가 필요하다** — `docs/registries/error-codes.yaml` 에 script/program 관련 code 가 하나도 없다(grep 확인). 신규 code 는 registry-governance 절차를 거쳐야 하고 owner 는 본 branch 다. `Category` enum 10종 밖의 값은 만들 수 없다(`shared/error/Category.java` 확인) |
|
||
|
||
<!-- section-id: implementation -->
|
||
## 구현 가이드
|
||
|
||
> 2026-07-28 `/branch-spec` 조사로 in-scope detail 을 채웠다. 근거가 *원칙*만 지지하고 *detail* 은 지지하지 않는 cell 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄로 표시했다(CLAUDE.md §15.5 R2). 다른 branch 결정 영역은 남기지 않았다(R3).
|
||
>
|
||
> ⚠️ **부분 재작성 상태 (다음 세션 최우선)** — §3·§8·§9 는 as-built 로 재작성했으나 **§1·§2·§6·§7 은 아직 초판의 Spring Data Redis 전제가 남아 있다.** 아래 as-built 사실과 충돌하는 서술이 그 4개 절에 있으므로, 착수 전 반드시 정정해야 한다:
|
||
>
|
||
> | as-built 사실 | 근거 | 충돌하는 절 |
|
||
> |---|---|---|
|
||
> | 리소스 경로는 `redis/scripts/*.lua` (`META-INF/scripts/` 아님) | `RedisProgramId` | §1 |
|
||
> | 로딩은 `ClassLoader.getResourceAsStream` + `RedisProgramCatalog.foundation()` (`ResourceScriptSource`/`DefaultRedisScript` 아님) | `RedisProgramCatalog` | §1·§2 |
|
||
> | descriptor 실제 필드 8개 = `id`·`sha256`·`scriptBytes`·`keyCount`·`argumentCount`·`maximumKeyBytes`·`maximumArgumentBytes`·`statuses` | `RedisProgramDescriptor` | §1 |
|
||
> | manifest 에 `complexity: "O(1)"`·`timeoutCertainty` 가 이미 있다 — §3 (1)층의 `writes` 신설 전에 이 필드 재사용을 먼저 검토 | `program-set.json` | §3 |
|
||
> | 실제 Redis lane 은 **Testcontainers 가 아니다** — `@Tag("redis-service")` + `./gradlew :adapter:outbound:cache-redis:redisServiceTest` + `-Dredis.test.host`/`-Dredis.test.port`. 모듈에 Testcontainers 참조 0건 | `build.gradle`, `LettuceRedisRuntimeServiceTest` | §6 |
|
||
> | typed facade 가 이미 있다 — `RedisAtomicPrimitives` 의 `compareAndDelete`/`compareAndExpire`/`setIfAbsentWithTtl`, 반환은 program 별 typed enum. 즉 **2층 구조**(executor seam = 상태 문자열 / facade = typed enum) | `RedisAtomicPrimitives` | §7·D8 |
|
||
>
|
||
> 모듈 좌표(`actually-implemented`): id `adapter-outbound-cache-redis`, Gradle `:adapter:outbound:cache-redis`, package root `dev.caskeleton.adapter.outbound.cache`. **이 저장소는 harness-free 라 `.harness/project/modules.yaml` 이 없다** — 초판의 그 인용은 낡은 clone 기준이었다.
|
||
|
||
### 1. program 카탈로그와 `ScriptDescriptor`
|
||
|
||
> **Trace**: D1 ← `spring-data-redis-scripting-execution.md#SDR-SCRIPT-C6`(classpath resource 표준 패턴)·`#SDR-SCRIPT-C5`(단일 인스턴스로 SHA1 재계산 회피)
|
||
>
|
||
> - **UNSUPPORTED_IMPL_DECISION**: descriptor 를 **YAML registry 로 둘지 Java enum 으로 둘지**. `#SDR-SCRIPT-C6` 은 script *본문*을 classpath resource 로 두라고만 말하고 메타데이터의 소재를 말하지 않는다. trade-off: 이 repo 는 계약 값을 `docs/registries/*.yaml` 로 관리하는 관행이 이미 있어(error-codes·env-keys·metrics) YAML 을 택하면 검증 스타일을 재사용할 수 있다. 대신 `.lua` 파일과 YAML 행이 물리적으로 분리돼 drift 가 생기므로 §3 의 정적 검사가 두 쪽을 대조해야 한다.
|
||
> - **UNSUPPORTED_IMPL_DECISION**: program id 명명 규칙. 공식 근거 없음. trade-off: 카탈로그가 능력 의미를 알면 안 되므로(D8) `rate-limit-token-bucket` 처럼 능력 이름을 쓰지 않고 `incr-with-ttl-if-absent` 처럼 **연산을 서술하는** 중립 이름을 쓴다.
|
||
|
||
| 항목 | planned 명세 | 근거 |
|
||
|---|---|---|
|
||
| script 본문 | `.lua` 파일 1개 = program 1개. `META-INF/scripts/` 아래 classpath resource | `#SDR-SCRIPT-C6` |
|
||
| 로딩 | `ResourceScriptSource(new ClassPathResource(...))` → `RedisScript.of(source, <resultType>)` | `#SDR-SCRIPT-C6` |
|
||
| 인스턴스 수명 | program 당 `DefaultRedisScript` 를 **컨텍스트에 단일 인스턴스**로 등록 — 매 실행 SHA1 재계산 금지 | `#SDR-SCRIPT-C5` |
|
||
| descriptor 필수 필드 | `programId` · `resourcePath` · `keyCount` · `argSchema` · `resultType` · `writes(boolean)` · `ownerBranch` | `UNSUPPORTED_IMPL_DECISION` (필드 집합은 공식 근거 없음). `resultType` 은 `#SDR-SCRIPT-C4` 의 4종으로 제한, `writes` 는 §3 이 요구 |
|
||
| 버전 식별 (**3축 분리**) | ① **계약 version** = program id 의 `-v1` 접미사 — 호환 불가 변경 시 신규 id 로 올린다(소비자가 pin 하는 축). ② **source digest** = SHA-256, manifest 와 카탈로그 대조용. ③ **실행 식별자** = SHA-1, EVALSHA 전송용으로만 쓰이며 계약 의미 없음 | **as-built** `RedisProgramId`(`compare-and-delete-v1` 등) · `RedisProgramDescriptor.sha256()` · `RedisLuaProgramExecutor.sha1()`. `#SDR-SCRIPT-C5` 는 SHA1 재계산 회피만 말하고 **버저닝 전략은 다루지 않는다**(그 문서의 does-not-prove) |
|
||
| golden vector | program 당 `(KEYS, ARGV) → 기대 반환` 고정 벡터 ≥1. descriptor 의 `keyCount`·`resultType` 과 대조 | `UNSUPPORTED_IMPL_DECISION` (형식은 공식 근거 없음). trade-off: 벡터가 없으면 script 수정이 조용히 반환 계약을 깬다 |
|
||
|
||
### 2. 실행 경로 — 위임과 pipeline 예외
|
||
|
||
> **Trace**: D2 ← `#SDR-SCRIPT-C1`(evalsha 시도 → 미적재 시 eval 폴백)·`redis-eval-scripting-atomicity.md#REDIS-EVAL-C3`(NOSCRIPT 복구) / D3 ← `#REDIS-EVAL-C4`(pipeline 안에서는 NOSCRIPT 를 handle 못 함)
|
||
>
|
||
> - **UNSUPPORTED_IMPL_DECISION**: 재적재 로직을 **직접 짜지 않는다**는 선택. 두 근거가 서로 다른 복구 경로를 말한다(Redis = `SCRIPT LOAD`+재시도 / Spring = `eval` 폴백). trade-off: 직접 구현하면 두 경로를 우리가 책임져야 하고, 위임하면 프레임워크 동작에 종속되지만 재적재 버그를 우리가 만들지 않는다. **어느 쪽이든 NOSCRIPT 재적재 test 는 실제 Redis 에서 `SCRIPT FLUSH` 후 재실행으로 증명해야 한다**(§6).
|
||
|
||
| 경로 | planned 동작 | 근거 |
|
||
|---|---|---|
|
||
| 단건 실행 | `execute(RedisScript, List<K> keys, Object... args)` — EVALSHA 우선은 프레임워크가 처리 | `#SDR-SCRIPT-C1`, `#SDR-SCRIPT-C3` |
|
||
| 캐시 소실 | 재시작·failover·`SCRIPT FLUSH` 로 언제든 발생 가능. 애플리케이션이 복구 책임을 진다 | `#REDIS-EVAL-C2`, `#REDIS-EVAL-C3` |
|
||
| pipeline | **EVALSHA 금지** — plain EVAL 로 되돌린다. pipeline 안에서는 NOSCRIPT 가 와도 처리할 수 없다 | `#REDIS-EVAL-C4` |
|
||
| 반환 타입 | `Long`·`Boolean`·`List`·역직렬화 값 중 하나. `OK` 류 상태 반환은 `null` | `#SDR-SCRIPT-C4` |
|
||
|
||
### 3. bounded execution — 강제 3층
|
||
|
||
> **Trace**: D4 ← `#REDIS-EVAL-C1`(실행 중 서버 전면 블로킹)·`#REDIS-EVAL-C6`(`SCRIPT KILL` 은 쓰기 안 한 스크립트만) / D5 ← `#REDIS-EVAL-C5`·`redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C5`(KEYS 선언) / sandbox ← `#REDIS-LUAAPI-C6`(전역 선언 차단)·`#REDIS-LUAAPI-C7`(`require` 비활성)
|
||
>
|
||
> - **UNSUPPORTED_IMPL_DECISION**: 정적 검사를 **ArchUnit 이 아니라 일반 JUnit test 로** 구현. ArchUnit 의 검사 대상은 JVM 바이트코드이므로 `.lua` 텍스트는 그 DSL 대상이 아니다. trade-off: 같은 test 스위트 안에 두어 실행 위치는 통일하되, 규칙은 텍스트 검사로 짠다.
|
||
> - **UNSUPPORTED_IMPL_DECISION**: 금지 토큰 목록(denylist)의 구체 항목과 파서 수준(정규식 vs Lua lexer). 공식 문서는 원칙만 말한다. trade-off: 정규식은 문자열 조립(`'S'..'MEMBERS'`)으로 우회되므로 **완전한 강제가 아님을 문서에 명시**하고, 우회는 리뷰가 맡는다.
|
||
|
||
| 층 | 무엇을 잡는가 | 무엇을 못 잡는가 | 근거 |
|
||
|---|---|---|---|
|
||
| (1) descriptor 선언 | `keyCount`·`writes` 를 program 이 스스로 선언 — 선언 없이는 카탈로그 등재 불가 | 선언 자체가 거짓인 경우 | D1 |
|
||
| (2) 빌드타임 정적 검사 | 하드코딩 key 리터럴, 비유한 명령 토큰, 무한 루프 리터럴, `KEYS[]` 미사용 | 문자열 조립 우회, **데이터 규모에 비례하는 사실상 무한** | `#REDIS-EVAL-C5`, `#REDIS-LUAAPI-C5` |
|
||
| (3) 서버 설정 + kill 런북 | 운영 데이터 규모에서만 드러나는 실행 시간 초과 | 근본 원인(미선언 key·비유한 명령) 자체 — 예방이 아니라 사후 대응 | `#REDIS-EVAL-C6`, `redis-programmability-max-execution-time.md#REDIS-PROG-C1`~`#REDIS-PROG-C4` |
|
||
|
||
(3)층의 확정된 값 (전부 `official-vendor-doc`):
|
||
|
||
| 항목 | 값 | 근거 |
|
||
|---|---|---|
|
||
| 최대 실행 시간 기본값 | **5초**, 설정명 `busy-reply-threshold` (밀리초 단위, `redis.conf` 또는 `CONFIG SET`) | `#REDIS-PROG-C1` |
|
||
| 임계 초과 시 | Redis 가 **자동 종료하지 않는다** — 종료하면 원자성 계약이 깨지므로 | `#REDIS-PROG-C2` |
|
||
| 초과 후 서버 상태 | 다른 클라이언트에 `BUSY` 응답. 허용 명령은 `SCRIPT KILL` · `FUNCTION KILL` · `SHUTDOWN NOSAVE` 뿐 | `#REDIS-PROG-C3` |
|
||
| 쓰기를 이미 수행한 경우 | **`SHUTDOWN NOSAVE` 만 가능** — 데이터셋을 디스크에 저장하지 않고 서버를 정지시킨다(사실상 강제 종료) | `#REDIS-PROG-C4`, `#REDIS-EVAL-C6` |
|
||
|
||
**(3)이 선택이 아니라 필수 병행인 이유**: 쓰기를 수행한 program 이 폭주하면 (1)(2)를 통과했더라도 죽일 수단이 `SHUTDOWN NOSAVE` — **인스턴스 강제 정지** 뿐이다(`#REDIS-PROG-C4`). 즉 bounded execution 위반의 최종 비용이 가용성 사고이므로 예방 층을 생략할 수 없다. 런북에 이 3단계(BUSY 관측 → 쓰기 여부 판별 → kill 또는 SHUTDOWN NOSAVE)를 명시한다.
|
||
|
||
program 작성 규칙 (카탈로그 등재 조건):
|
||
|
||
| 규칙 | 내용 | 근거 |
|
||
|---|---|---|
|
||
| KEYS 선언 | 접근하는 모든 key 를 `KEYS` 인자로 받는다. 프로그램 생성 key·데이터 내용 기반 key 접근 금지 | `#REDIS-EVAL-C5`, `#REDIS-LUAAPI-C5` |
|
||
| 전역 금지 | 모든 선언은 `local`. 전역 변수·함수 선언은 샌드박스가 차단 | `#REDIS-LUAAPI-C6` |
|
||
| 모듈 금지 | `require` 비활성 — 허용된 런타임 라이브러리만 사용 | `#REDIS-LUAAPI-C7` |
|
||
| 복제 | effects replication 이 Redis 7.0 기준 유일 모드이며 정의상 결정론적이다. `redis.replicate_commands()` 를 호출하지 않는다 | `#REDIS-EVAL-C7`, `#REDIS-LUAAPI-C3`·`#REDIS-LUAAPI-C4` |
|
||
| **오류 처리** | `redis.call` 을 쓰되 **오류가 날 상황을 미리 방어**한다 — 진입부에서 `#KEYS`/`#ARGV`/길이를 검사해 `INVALID`, `TYPE` 을 확인해 `WRONG_TYPE` 을 **상태 문자열로 반환**한다. `redis.pcall` 과 `redis.error_reply` 는 as-built 가 쓰지 않는다 | `#REDIS-LUAAPI-C1`(`call` 은 오류를 그대로 올려 스크립트 중단) + **as-built** `.lua` 3개 전부 이 패턴 |
|
||
|
||
**as-built 가 `pcall`/`error_reply` 대신 방어적 상태 반환을 택한 결과**: 예상 가능한 실패(잘못된 arity·타입 불일치·소유자 불일치)는 **오류가 아니라 계약된 상태값**으로 나오고, `redis.call` 이 실제로 오류를 올리는 경우는 *예상 못 한* 결함뿐이다. 즉 상태값 = 정상 흐름, 예외 = 결함이라는 경계가 script 안에서 이미 갈린다.
|
||
|
||
### 4. 배포 모드 — EVAL script 기본, Functions opt-in 게이트
|
||
|
||
> **Trace**: D6 ← `redis-functions-library-deployment.md#REDIS-FN-C1`(7.0+)·`#REDIS-FN-C2`(영속·복제 vs 휘발성 캐시)·`#REDIS-FN-C6`(cluster 전 노드 수동 적재)·`#REDIS-FN-C4`(Functions 도 동일 블로킹) / `#REDIS-EVAL-C2`(캐시 휘발)
|
||
|
||
| opt-in 조건 | 판정 | 근거 |
|
||
|---|---|---|
|
||
| ① 대상 Redis 7.0+ 확정 | **충족** — `program-set.json` 이 `minimumRedisVersion: "7.2"` 를 선언한다 | `#REDIS-FN-C1` + as-built manifest |
|
||
| ② NOSCRIPT 재적재 부담이 실제 문제로 관측됨 | 미관측 (구현 전) | `#REDIS-FN-C2`, `#REDIS-EVAL-C2` |
|
||
| ③ cluster 전 노드 수동 적재 운영을 수용 | 미결정 | `#REDIS-FN-C6` |
|
||
|
||
→ **3조건 모두 충족 전까지 EVAL script 가 기본값이다.** Functions 로 전환하면 SSOT 가 library 파일(`#!lua name=…`)로 이동하고(`#REDIS-FN-C3`) §1 의 SHA1 기반 버전 식별이 성립하지 않으므로, 전환은 D1 재검토를 동반한다.
|
||
|
||
### 5. unsafe recipe ↔ 안전한 대체 쌍 카탈로그
|
||
|
||
> **Trace**: D7 ← `redis-incr-rate-limiter-pattern.md#REDIS-INCR-C4`(공식이 두 변형에 서로 다른 대체를 권고)·`#REDIS-INCR-C2`(race 명시)·`#REDIS-INCR-C3`(EXPIRE 유실 → key leak)
|
||
>
|
||
> - 이 표의 **행 자체가 계약**이다. helper 를 추가할 때 unsafe 쌍과 경합 test 없이 등재할 수 없다.
|
||
|
||
| unsafe recipe | 분기 | 만드는 경합 | 안전한 대체 | 근거 |
|
||
|---|---|---|---|---|
|
||
| 고정 타임스탬프 키 카운터 — `INCR` 후 `EXPIRE` | 없음 | `INCR` 은 성공했는데 `EXPIRE` 가 유실될 수 있음 | **`MULTI`/`EXEC` 로 충분 — program 을 만들지 않는다** | `#REDIS-INCR-C4`(a) |
|
||
| 단일 카운터 — `GET` → 판단 → `INCR` → 조건부 `EXPIRE` | 있음(`IF value == 1`) | 공식이 "race condition" 을 명시. `EXPIRE` 미수행 시 key 가 TTL 없이 leak | **atomic program 필수** (Lua/EVAL) | `#REDIS-INCR-C2`·`#REDIS-INCR-C3`·`#REDIS-INCR-C4`(b) |
|
||
|
||
두 변형을 하나의 "INCR+EXPIRE" 항목으로 뭉치면 `#REDIS-INCR-C4` 의 구분이 사라진다 — **별개 행으로 유지한다.**
|
||
|
||
### 6. 테스트 계약 — 경합 증명 2층
|
||
|
||
> **Trace**: D9 ← `#REDIS-EVAL-C1`(원자성은 서버가 보장)·`test-taxonomy-testcontainers-official.md#TC-OFFICIAL-C3`(mock/in-memory 없이 실제 서비스)·`#TC-OFFICIAL-C5`(in-memory 는 기능 결여)
|
||
>
|
||
> - **선행 계약 (다른 branch owner, 재진술 아님)**: focused(unit/contract/architecture) 레벨의 Testcontainers 금지는 [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] 가 소유하며 ca-tmpl 에 **ArchUnit 으로 실제 강제**돼 있다 — `TestTaxonomyArchitectureTest.contractLevelTestsHaveNoTestcontainersDependency` / `architectureLevelTestsHaveNoTestcontainersDependency` + 비공허성 증명 `banRuleFiresOnTestcontainersUsage` (등급 `actually-implemented`, `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TestTaxonomyArchitectureTest.java`).
|
||
> - **UNSUPPORTED_IMPL_DECISION**: 반복 횟수·스레드 수. 어떤 공식 문서도 수치를 권고하지 않는다. trade-off: barrier 로 순서를 강제하면 확률적 반복에 의존하지 않으므로 반복 수를 작게 유지한다 — `flaky-quarantine.yaml` 의 14일 sunset 압력을 피하는 방향.
|
||
|
||
| 층 | 무엇을 증명 | 어떻게 | 왜 이 층인가 |
|
||
|---|---|---|---|
|
||
| focused (unit/contract) | unsafe recipe 의 race 가 **실제로 발생함** | fake `RedisClient` + latch/barrier 로 read 와 write 사이 인터리빙을 **결정론적으로 강제** | race 는 클라이언트 측 순서 문제라 서버 없이 재현 가능. 실제 네트워크 금지 규칙 준수 |
|
||
| focused (unit/contract) | atomic 대체에 **재현 가능한 client-side window 가 없음** | 성공 경로에서 round-trip 이 정확히 1회임을 spy 로 assert | 구조적 회귀 방지 — 나중에 누가 보조 `GET` 을 끼워 2-round-trip 으로 되돌리는 것을 잡는다 |
|
||
| integration | atomic 대체의 **실제 원자성** + NOSCRIPT 재적재 | 실제 Redis(Testcontainers) — 동시 호출 후 불변식 유지 확인, `SCRIPT FLUSH` 후 재실행 | 원자성은 서버가 보장하는 성질이라 fake 로 증명 불가(`#REDIS-EVAL-C1`). in-memory 대체는 기능 결여 위험(`#TC-OFFICIAL-C5`) |
|
||
|
||
내부 선례(등급 `actually-implemented`): latch 기반 결정론적 동시성 test 는 이 repo 에 이미 있다 — `src/adapter/outbound/persistence-jpa/.../lock/LockRegistryDistributedLockAdapterTest.java` (`CountDownLatch` 사용).
|
||
|
||
**환경 게이트**: Redis 를 요구하는 test 는 `@EnabledIfRedisCacheEnabled`(`APP_CACHE_REDIS_ENABLED=true` 일 때만 실행, 아니면 SKIPPED) 로 감싼다 — 등급 `actually-implemented`, `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfRedisCacheEnabled.java`.
|
||
|
||
### 7. typed facade 와 소비 경계
|
||
|
||
> **Trace**: D8 ← `#SDR-SCRIPT-C3`(execute 시그니처)·`#SDR-SCRIPT-C4`(반환 타입 제약) + 소비 branch 4건이 명시적으로 "Lua program 실행 기반 — #062 소유" 로 위임
|
||
>
|
||
> - **UNSUPPORTED_IMPL_DECISION**: facade 타입·메서드 명명. 공식 근거 없음. trade-off: 능력 이름이 들어가면 D8 이 깨지므로 연산 서술 이름만 쓴다.
|
||
|
||
| 규칙 | planned 동작 |
|
||
|---|---|
|
||
| 노출 | program 식별자 + KEYS + ARGV + 반환 타입. **그 이상 없음** |
|
||
| 비노출 | rate limit 판정·lock fencing·idempotency claim 결과 등 능력 의미 — 각 소비 branch 소유 |
|
||
| 반환 | `#SDR-SCRIPT-C4` 의 4종을 넘는 복합 구조가 필요하면 program 이 직렬화 형식을 정하고 facade 가 파싱. 그 형식 결정은 근거가 없다(`UNSUPPORTED_IMPL_DECISION`) |
|
||
| 소비자 | [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]] · [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] · [[raw/branch-notes/feature-fenced-lock-coordination-contract]] · [[raw/branch-notes/feature-cache-strategy-registry-contract]] |
|
||
|
||
### 8. 실패 등급과 error code
|
||
|
||
> **Trace**: D10 ← `#REDIS-LUAAPI-C1`(`call` 은 예외를 그대로 올려 중단 / `pcall` 은 error 테이블 반환)·`#REDIS-LUAAPI-C2`(`error_reply`/`status_reply` 가 typed 반환 수단). 등급 분리 규칙 자체의 owner 는 hub §11 매트릭스.
|
||
>
|
||
> - **신규 제안임을 명시** — `docs/registries/error-codes.yaml` 에 script/program 관련 code 가 **하나도 없다**(grep 확인). 아래는 기존 값이 아니라 제안이며 registry-governance 절차를 거쳐야 한다.
|
||
|
||
**등급 판별은 as-built 예외 타입으로 결정론적으로 갈린다** — 추론이 아니라 타입 매칭이다:
|
||
|
||
| as-built 신호 | 의미 | 등급 | 제안 category | 근거 |
|
||
|---|---|---|---|---|
|
||
| `RedisCommandFailureException(kind=UNAVAILABLE)` | 연결 불가 | backend 실패 → 선언된 경우 degrade 가능 | `TRANSIENT_DEPENDENCY` | 기존 `CACHE_UNAVAILABLE`(503, retryable) 선례 |
|
||
| `RedisCommandFailureException(kind=OVERLOADED)` | admission 거부(in-flight 상한) | backend 실패 | `TRANSIENT_DEPENDENCY` | `RedisCommandAdmission` — 단 이 상한의 owner 는 #061 |
|
||
| `RedisProgramCompatibilityException` | 반환 상태가 `descriptor.statuses()` 밖 | **program 결함 → fail closed** | `INTERNAL` | hub §11 "codec·프로그래밍 결함은 fail closed" |
|
||
| `IllegalArgumentException` (arity·바이트 상한·카탈로그 소유권) | 호출 계약 위반 | **program 결함 → fail closed** | `INTERNAL` | `RedisLuaProgramExecutor.validate()` |
|
||
| 계약된 상태 문자열 (`INVALID`·`WRONG_TYPE`·`NOT_OWNER` 등) | **실패가 아니다** — 정상 반환 | 호출자가 의미를 해석 | — | §3 오류 처리 규칙 |
|
||
|
||
`RedisCommandFailureException.certainty()` 는 `NOT_APPLIED` / `INDETERMINATE` 를 구분한다 — **재시도 안전성 판단에 필요**하다. `INDETERMINATE`(적용 여부 불명)를 무조건 재시도하면 program 이 원자적이어도 **중복 적용**이 생길 수 있다. 이 축의 owner 는 #061(전송 계층)이며 본 branch 는 소비한다.
|
||
|
||
`Category` 는 `shared/error/Category.java` 의 **10종 밖 값을 만들 수 없다** (`VALIDATION`·`AUTH`·`AUTHZ`·`NOT_FOUND`·`CONFLICT`·`RATE_LIMIT`·`TRANSIENT_DEPENDENCY`·`PERMANENT_DEPENDENCY`·`DATA_INTEGRITY`·`INTERNAL`, 등급 `actually-implemented`).
|
||
|
||
`redis.error_reply` 는 as-built 가 쓰지 않는다 — §3 의 방어적 상태 반환이 그 역할을 대신한다. `#REDIS-LUAAPI-C2` 는 typed 오류 반환의 *공식 수단*을 증명할 뿐 as-built 가 그것을 쓴다는 근거가 아니다.
|
||
|
||
### 9. program 실행 관측 — metric + span
|
||
|
||
> **Trace**: D11 ← [[raw/branch-notes/feature-metrics-alerting-contract]] `D2`(Micrometer dot.case + unit suffix 명명)·`D8`(high-cardinality tag 금지 + bounded whitelist) — 그 branch 가 owner 이고 본 §는 소비자다. 선례: `metrics.yaml` 의 `lock.acquisition`(owner `feature-distributed-lock-contract`) — "key 는 tag 로 넣지 않는다(무한 cardinality)".
|
||
>
|
||
> - **UNSUPPORTED_IMPL_DECISION**: metric 이름과 `outcome` 어휘. 공식 근거 없음. trade-off: `lock.acquisition` 의 형태(counter + `outcome` 단일 bounded tag)를 그대로 따라 registry 스타일 일관성을 택했다. `program_id` 를 tag 로 **추가**한 것은 카탈로그가 닫힌 enum(현재 3개)이라 cardinality 가 구조적으로 유한하기 때문이다.
|
||
> - **UNSUPPORTED_IMPL_DECISION**: cardinality 상한 수치. metrics-alerting `D8` 자체가 정량 상한(≤200/≤50)을 "공식 spec 없는 운영 가정" 으로 라벨했으므로 본 §도 같은 성격이다.
|
||
|
||
| 항목 | planned 명세 | 근거 |
|
||
|---|---|---|
|
||
| metric | `redis.program.executions.total` (counter, unit `total`) | metrics-alerting `D2` 명명 규약 |
|
||
| tag `program_id` | 값 = `RedisProgramId.externalId()`. cardinality 는 **닫힌 enum 크기**로 bounded (현재 3, 상한 50 제안) | as-built 카탈로그가 `EnumMap` 닫힌 집합 |
|
||
| tag `outcome` | `success` · `noscript_reload` · `defect` · `backend_failure` (4종 고정) | §8 의 예외 타입 매핑과 1:1 |
|
||
| **금지 tag** | key·KEYS 값·ARGV 값·상태 문자열 원문 — 사용자 입력 유래이거나 무한 cardinality | metrics-alerting `D8`, `lock.acquisition` 선례 |
|
||
| alert | `defect` rate 지속 > 0 이면 P2 — program 결함은 조용히 넘어가면 안 된다 | hub §11 fail-closed 원칙 |
|
||
| trace span | program 실행 1회 = span 1개. attribute 는 `program_id`·`outcome` 만. **key·ARGV 를 span attribute 로 넣지 않는다** | hub §36.2 "bounded cardinality metric 과 trace span" |
|
||
| `noscript_reload` 의 쓰임 | 이 값이 **꾸준히 0 이 아니면** 캐시가 반복 소실된다는 신호 — D2 의 EVAL 폴백이 정상 경로가 되어버린 상태를 드러낸다 | `#REDIS-EVAL-C2`(캐시 휘발성) |
|
||
|
||
**registry 등재**: 위 metric 은 `docs/registries/metrics.yaml` 에 **신규 제안** 이다(현재 script/program/redis 관련 row 0건 — grep 확인). `owner_branch: feature-redis-atomic-program-catalog-contract`, `compatibility_impact: additive`, `required_test: contract-verification:metrics-cardinality` 로 등재하며 절차는 [[raw/branch-notes/feature-contract-registry-governance]] 소관이다.
|
||
|
||
<!-- section-id: edge-failure-dependency -->
|
||
## 엣지·실패·의존
|
||
|
||
- **실패·엣지 경로**
|
||
- **스크립트 캐시 소실** — 재시작·failover·`SCRIPT FLUSH` 로 언제든 발생. EVALSHA 가 `NOSCRIPT` 를 받는다 → 재적재 후 재시도(§구현 가이드 2). 애플리케이션 책임임이 공식 명시(`#REDIS-EVAL-C2`·`#REDIS-EVAL-C3`)
|
||
- **pipeline 안의 NOSCRIPT** — 처리 불가. pipeline 경로는 애초에 plain EVAL 로 되돌린다(`#REDIS-EVAL-C4`, D3)
|
||
- **폭주 program 이 쓰기를 이미 수행함** — `SCRIPT KILL` 로 죽일 수 없다(`#REDIS-EVAL-C6`). 5초(`busy-reply-threshold`) 초과 후 서버는 다른 클라이언트에 `BUSY` 로 응답하고, 이 상태에서 남은 수단은 **`SHUTDOWN NOSAVE` — 인스턴스 강제 정지뿐**이다(`#REDIS-PROG-C1`·`#REDIS-PROG-C3`·`#REDIS-PROG-C4`). 즉 이 엣지의 최종 비용은 가용성 사고다(§구현 가이드 3)
|
||
- **미선언 key 접근** — Redis 가 막아주지 않는다(`#REDIS-EVAL-C5` 의 does-not-prove: 거부 동작·에러 코드가 공식에 없음). 우리가 정적 검사로 잡지 못하면 조용히 통과한다
|
||
- **program 결함** — fail closed. cache miss 로 위장하지 않는다(hub §11, D10)
|
||
- **descriptor ↔ `.lua` drift** — `keyCount`/`resultType` 은 수기 선언이라 본문 변경 시 자동 갱신되지 않는다. golden vector 가 이 drift 를 잡는 유일한 장치(§구현 가이드 1)
|
||
- **경합 test 자체의 flakiness** — 확률적 재현에 의존하면 `flaky-quarantine.yaml` 의 14일 sunset 압력을 받는다. barrier 로 순서를 강제해 회피(§구현 가이드 6)
|
||
- **다른 계약 의존** (대상 브랜치 + 그 Decision ID)
|
||
- [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] (`WI-…-061`) — 그 branch 가 **이미 D1~D22 를 보유**하며(스캐폴딩 아님) 다음을 소유한다: `D5`(key builder)·`D6`(digest)·`D8`(key/payload/policy **3 version 축 분리**)·`D11`(envelope)·`D10`(`byte[]` codec) 은 **program descriptor 의 입력**이고, `D16`~`D18`(Lettuce ClientOptions·timeout·queue) 은 본 branch 의 실행 경로가 올라타는 전송 계층이다. `D2`(SDK 선택)가 raw Lettuce 로 확정된 것이 본 branch D1·D2 의 전제다
|
||
- [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] `D3`(focused 레벨 Testcontainers 금지, ArchUnit 강제)·`D4`(CI 시간 budget) — D9 의 2층 분리는 이 계약의 *결과*이지 본 branch 의 재량이 아니다(재진술 아님, 포인터만)
|
||
- **위임을 선언한 소비 branch (2건, 확인됨)** — [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]] (§제외 범위 "Lua program 실행 기반 — #062 소유") · [[raw/branch-notes/feature-fenced-lock-coordination-contract]] (§제외 범위 "Redis 연결·스크립트 기반 — #061·#062 소유")
|
||
- **위임 미선언 소비 branch (2건)** — [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] · [[raw/branch-notes/feature-cache-strategy-registry-contract]] 는 §제외 범위에 program 위임 문구가 **없다**(grep 0건). hub §25 Owner Map 은 이 둘을 consumer 로 적지만 노트 쪽 선언이 비어 있어 **양방향 정합이 깨져 있다** — `/sync` 로 맞춰야 하며, 그 전까지 "4건 모두 위임" 이라고 주장할 수 없다
|
||
- [[raw/branch-notes/feature-operational-error-observability-foundation]] — D10 의 신규 error code 는 `error-codes.yaml` 스키마와 `Category` enum(10종)을 따른다. 해당 branch 의 category enum owner D-row 를 pin 해야 하나 아직 번호 미확인(`Should-fix`)
|
||
- [[raw/branch-notes/feature-capability-provider-selection-contract]] (`WI-…-060`, **선행 필수**) — D6 의 배포 모드(EVAL script ↔ Functions) 는 hub §25 기준 **capability 활성화·provider 선택**의 일종이며, 그 판정은 "typed descriptor 로만" 하고 bean 이름·classpath 존재를 근거로 삼지 않는다. 본 branch 는 3조건 opt-in *논리*만 소유하고, 그 스위치가 어떤 typed descriptor 로 노출되는지는 그 branch 소유다
|
||
- [[raw/branch-notes/feature-metrics-alerting-contract]] `D2`(dot.case 명명)·`D8`(cardinality bounds·금지 tag) — D11 의 metric·span 은 이 계약의 소비자다. `D8` 이 바뀌면 §구현 가이드 9 의 tag 집합이 함께 바뀐다
|
||
|
||
<!-- section-id: claims-to-verify -->
|
||
## 검증해야 할 주장 / Claims To Verify
|
||
|
||
| Claim | Why uncertain | How to verify | Status |
|
||
|---|---|---|---|
|
||
| 배포 이미지의 실제 `redis_version` 이 7.2 이상이다 | `program-set.json` 이 `minimumRedisVersion: "7.2"` 를 **선언**하지만, 그것이 배포 환경에서 실제로 지켜지는지는 별개다. 미달이면 D6 의 조건 ①과 `#REDIS-EVAL-C7`(effects replication 유일 모드)이 함께 깨진다 | 배포 대상에서 `INFO server` 의 `redis_version` 확인 + startup 시 최소 버전 검증을 둘지 결정 | `needs-confirmation` |
|
||
| as-built 의 EVAL 폴백이 Redis 공식 절차와 동등하다 | as-built 는 `NOSCRIPT` 시 **EVAL 직접 실행**으로 복구하고, `#REDIS-EVAL-C3` 은 "`SCRIPT LOAD` 후 EVALSHA 재시도" 를 서술한다 — 서로 다른 경로다. EVAL 이 캐시에 넣는 것은 부수효과이며 코드가 관측하지 않는다 | 실제 Redis lane 에서 `SCRIPT FLUSH` → 실행 → `SCRIPT EXISTS <sha>` 로 재적재 여부 확인 | `needs-confirmation` |
|
||
| D3(pipeline plain EVAL)이 공허하지 않다 | as-built `RedisBinaryCommands` 는 `get`/`set`/`delete`/`evalSha`/`eval` 5개뿐이고 **pipeline 표면이 없다**. 즉 D3 은 현재 도달 불가능한 조건 위의 결정이다 | pipeline API 를 실제로 추가할지 결정 → 추가하지 않으면 D3 을 "향후 확장 시 제약" 으로 재분류 | `planned` |
|
||
| 정적 검사가 bounded execution 위반을 실효적으로 잡는다 | 정규식 텍스트 검사는 문자열 조립(`'S'..'MEMBERS'`)으로 우회된다. "완전한 강제" 라는 인상만 주고 실제로는 부분 강제일 수 있다 | 의도적 우회 fixture 를 넣어 검사기가 놓치는지 확인(비공허성 증명). 놓치면 그 한계를 문서에 명시 | `planned` |
|
||
| barrier 로 강제한 인터리빙이 매 실행 race 를 재현한다 | barrier 는 read/write 의 **논리적 순서**만 강제하고 서버가 명령을 수신하는 wire-order 까지 통제하지 못한다 | 동일 test 를 CI 에서 연속 반복 실행해 재현율 측정. 100% 가 아니면 quarantine 대상이 된다 | `planned` |
|
||
| atomic 대체가 실제로 경합을 없앤다 | 원자성은 서버가 보장하는 성질이라 fake 로 증명 불가(`#REDIS-EVAL-C1`). in-memory 대체도 기능 결여 위험(`#TC-OFFICIAL-C5`) | 실제 Redis 통합 test — 동시 호출 후 불변식 유지 확인. 이는 통계적(위반 미관측) 증명이지 결정론적 증명이 아님을 명시 | `planned` |
|
||
| golden vector 가 descriptor drift 를 잡는다 | `keyCount`·`resultType` 은 수기 선언이고 `.lua` 본문 변경 시 자동 갱신되지 않는다 | descriptor 는 그대로 두고 `.lua` 의 KEYS 사용 개수만 바꾼 fixture 로 test 가 실패하는지 확인 | `planned` |
|
||
| 신규 error code 가 registry 절차를 통과한다 | `error-codes.yaml` 에 script/program code 가 하나도 없어 전부 신규 제안이다. `Category` 는 10종 밖으로 못 나간다 | registry-governance 절차 + `contract-verification` test 로 매핑 검증 | `planned` |
|
||
| **`maxmemory` 초과 상태에서 program 이 abort 된다** | D4 는 bounded *시간*만 다루고 bounded *메모리*는 다루지 않는다. "maxmemory 초과 시 추가 메모리를 쓰는 첫 write 가 스크립트를 abort 시킨다"는 서술은 수집한 두 자료(`redis-programmability-max-execution-time`·`redis-key-eviction-maxmemory-policy`) 어디에도 없어 **인과관계가 미증명**이다 | 공식 근거를 별도 수집하거나 실제 Redis 에서 `maxmemory` 근접 상태로 write program 실행해 재현. 확인되면 D4 에 메모리 축을 추가하고, `maxmemory-policy` 값 선택은 #061(배포 설정 소유)로 위임 | `needs-confirmation` |
|
||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 소비 branch 4건 중 **2건만** 위임을 선언했고(edge-rate-limit·fenced-lock), idempotency·cache-strategy 는 아직 scaffolding 이라 선언이 없다 | 그 2건 `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||
|
||
## Audit & Findings
|
||
|
||
> `/branch-spec` 2026-07-28 조사에서 발견한 **상위 문서의 사실 오류**. 대상은 hub / 설계 문서이므로 본 branch 는 정합 권고만 내고 자동 수정하지 않는다.
|
||
|
||
| Finding ID | 대상 | 현재 서술 | 조사 결과 | 권고 | 처리 |
|
||
|---|---|---|---|---|---|
|
||
| `GROUND_TRUTH_PATH_DRIFT` | `.claude/commands/branch-spec.md` §참조 + llm-wiki 다수 문서 | ca-tmpl ground truth 경로를 `/home/donghyeon/workspace/ca-tmpl/` 로 지정 | **그 경로는 낡은 clone 이다.** 실제 최신 저장소는 `/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/` 이며, `cache-redis` 가 47개 src 파일을 보유하고 `RedisProgramCatalog`·`RedisProgramDescriptor`·`RedisProgramExecutor`·`RedisLuaProgramExecutor`·`RedisProgramId`·`LettuceRedisRuntime`·`RedisKeyBuilder`·`RedisCacheEnvelopeCodec` + `.lua` 3개 + `program-set.json` 이 모두 존재한다. 낡은 clone 에는 14개뿐이라 "코드 없음" 으로 오판하기 쉽다 | 명령 문서의 ground truth 경로를 정정. 같은 사실을 [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] §Audit A1 과 설계 §13 D2 가 독립적으로 기록했다 | `open` — harness/사용자 소유 |
|
||
| `SELF_CORRECTION` (본 세션) | 본 노트 초판의 `CODE_CLAIM_DRIFT` finding | 초판은 "4개 클래스가 어디에도 없고 commit `b3add01` 도 없으므로 hub §35 를 `[ ]`(미시작)로 강등하라" 고 권고했다 | **그 권고는 틀렸다.** 낡은 clone(`/home/donghyeon/workspace/ca-tmpl`)만 조회한 결과이며, 실제 저장소에는 4개 클래스가 모두 존재한다. hub §35 의 `[*]`(부분 구현) 표기가 **맞다**. 설계 §14.2 의 `actually-implemented` 등급도 유효하다(단 파일 수 "43개" 는 현재 47개로 증가) | hub·설계 문서에 대한 강등 권고를 **철회**한다. 남는 정정 대상은 파일 수 숫자와 commit 해시(`b3add01` 은 낡은 clone 기준 미존재)뿐이며 경미하다 | `partially-resolved` 2026-07-28 — D-row(D1·D2·D4·D8·D9·D10·D11)·§구현 가이드 3·8·9·§TODO·§결정 사항·§진행 중 메모·§Claims To Verify 는 as-built 로 재작성했다. **§구현 가이드 1·2·6·7 은 아직 초판 전제가 남아 있다** — §구현 가이드 preamble 의 충돌 표 참조 |
|
||
| `SPRING_DATA_REDIS_PREMISE_RESOLVED` | 본 노트 초판 D1·D2 | 초판은 D1·D2 를 Spring Data Redis 의 `RedisScript`/`DefaultRedisScript`/`ScriptExecutor` 전제 위에 세웠다 | **as-built 는 `io.lettuce:lettuce-core` 단독이고 `spring-data-redis` 의존성이 없다**(`cache-redis/build.gradle` 확인). EVALSHA→NOSCRIPT→EVAL 폴백은 `RedisLuaProgramExecutor` 가 직접 구현한다 | D1·D2 를 as-built 기준으로 재작성했다. [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] §Audit A2 가 이 충돌을 `SPRING_DATA_REDIS_PREMISE_CONFLICT` 로 열어두고 본 노트를 가리키는데, **본 노트 쪽은 해소됐으므로 그 항목과 §구현 가이드 4 의 위험 서술이 낡았다** — 그 branch 소유라 여기서 고치지 않고 알림만 남긴다 | `resolved` (본 노트 측) / #061 측 갱신 필요 |
|
||
| `NO_VERSION_PIN` **(철회)** | 본 branch D6 의 opt-in 조건 ① | 초판은 "ca-tmpl 에 Redis 버전이 고정된 곳이 없어 7.0+ 판정 불가" 라고 단정했다 | **틀렸다 — 낡은 clone 기준이었다.** 실제 저장소의 `redis/program-set.json:3` 에 `"minimumRedisVersion": "7.2"` 가 있다. 즉 D6 의 조건 ①(7.0+)은 **이미 충족**이고, `#REDIS-EVAL-C7`(effects replication 유일 모드, 7.0+ 전제)도 확정으로 승격된다 | D6 게이트를 ②③ 로 좁혔다. 남는 확인은 "배포 이미지의 실제 `redis_version` 이 7.2 이상인가" 라는 운영 확인뿐 | `resolved` 2026-07-28 |
|
||
| `DANGLING_DESIGN_REF` **(철회)** | 본 노트 §근거의 "프로젝트 내부 설계 참조" | 초판은 "두 설계 문서가 부재하므로 인용 근거에서 제외" 라고 권고했다 | **틀렸다 — 낡은 clone 기준이었다.** 실제 저장소 `docs/superpowers/specs/` 에 `2026-07-26-production-capability-platform-design.md` 와 `2026-07-26-redis-production-capability-design.md`(as-built 를 낳은 6,700줄 설계)가 **둘 다 실재**한다 | 두 문서를 `internal-design-doc` 등급으로 §근거에 유지한다. **다만 본 노트의 현재 결정은 아직 이 문서들을 읽고 쓴 것이 아니다** — 다음 세션에서 정독해 §1 golden vector·§9 metric·§3 denylist·BUSY 관측의 `UNSUPPORTED_IMPL_DECISION` 라벨을 항목별로 재판정해야 한다 | `open` — 정독 미완 |
|
||
| `STALE_CLONE_ROOT_CAUSE` | 본 세션의 조사 방법 | 위 `SELF_CORRECTION`·`NO_VERSION_PIN`·`DANGLING_DESIGN_REF` 3건이 모두 같은 원인에서 나왔다 | `/branch-spec` 명령 문서가 지정한 `/home/donghyeon/workspace/ca-tmpl` 를 ground truth 로 삼은 결과, **"코드 없음·버전 없음·설계문서 없음" 이라는 3중 오판**이 연쇄했다 | 명령 문서의 ground truth 경로를 정정하기 전까지 이 branch 계열의 조사는 반드시 `desktop-server-git/clean-architecture-backend-template/` 를 쓴다 | `open` — harness/사용자 소유 |
|
||
| `EVALSHA_COUNTEREXAMPLE` | 본 branch D2 (EVALSHA 우선) | 완료 조건이 "NOSCRIPT 재적재 test" 를 요구 → EVALSHA 우선을 전제 | 조사 중 LINE engineering 이 Lua 를 쓰되 **EVALSHA 를 의도적으로 거부**한 사례가 확인됐다(노드·키 관리 비용 vs 대역폭 절감을 비교한 결과). 다만 **아직 raw 로 archive 하지 않아 근거로 인용하지 않았다** | 클러스터 노드 수·호출 빈도가 확정되면 EVALSHA 의 실익을 재평가. 필요 시 해당 블로그를 `raw/company-tech-blogs/` 로 수집 후 D2 재검토 | `open` — 근거 미수집, 결정 변경 없음 |
|
||
|
||
## Coverage / 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||
|
||
> `coverage-auditor` 생성물 — 손으로 유지하지 않는다. 2026-07-28 loop 2/2 판정: **Covered** (Blocking 0 / Should-fix 0 / Advisory 3).
|
||
> governing doc = [[raw/project-notes/ca-skeleton-operational-contract]] (§8.0 WI-062 · §25 Owner Map · §36.2 capability card · §11 실패 매트릭스). 코드 대조는 `desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/`.
|
||
|
||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||
|--------|------|-------|--------|------|
|
||
| WI-062 ① program descriptor | covered-here | — | — | D1 · `RedisProgramDescriptor.java` 실재 |
|
||
| WI-062 ② golden vector | covered-here | — | — | D1 · `RedisProgramCatalogTest.loadsEveryFoundationProgramWithAnExactDigestAndBoundedSignature` |
|
||
| WI-062 ③ NOSCRIPT 재적재 test | covered-here | — | — | D2 · `RedisLuaProgramExecutorTest.fallsBackToEvalOnlyWhenEvalShaReportsNoScript` |
|
||
| WI-062 ④ unsafe recipe 경합 재현 test | covered-here (결정) / 코드 `planned` | — | — | D9 + §구현 가이드 6. 모듈에 latch/thread 코드 0건 — 노트의 "3 구현 / 1 미구현" 자기평가가 코드와 일치함이 검증됨 |
|
||
| §36.2 bounded cardinality metric · trace span | covered-here | — | — | **D11** + §구현 가이드 9 (loop 1 의 🔴 Blocking 해소) |
|
||
| §36.2 나머지 12항목 (provider ID·readiness·비용모델·rolling upgrade·runbook 등) | delegated | `feature-capability-provider-selection-contract` | — | hub §36 헤더가 그 branch 를 card owner 로 지정. 본 branch 는 Tier T(기술 런타임)이므로 card 전체를 채울 의무가 없다 |
|
||
| §11 실패 정책 매트릭스 정합 | covered-here (consumer) | 규칙 owner: `feature-capability-provider-selection-contract` | — | D10 · `Category.java` 10종 enum 과 노트 열거가 100% 일치 확인 |
|
||
| 배포 모드 typed descriptor 노출 | delegated + linked | `feature-capability-provider-selection-contract` | — | §엣지·실패·의존 |
|
||
| cluster / hash-slot 라우팅 | delegated + linked | `feature-redis-runtime-role-isolation-contract` | — | §엣지·실패·의존 (#061 D5·D6·D8·D10·D11·D16~D18 pin) |
|
||
| idempotency branch 의 위임 미선언 | delegated (consumer 쪽 미선언) | `feature-idempotency-ownership-protocol-contract` | ⚪ Advisory | 그 노트가 아직 scaffolding(D-row 0건). 본 branch 는 이미 gap 을 서술·귀속했으므로 `UNLINKED_DELEGATION` 아님 — 그쪽 `/branch-spec` 으로 해소 |
|
||
| cache-strategy branch 의 위임 미선언 | delegated (consumer 쪽 미선언) | `feature-cache-strategy-registry-contract` | ⚪ Advisory | 위와 동일 |
|
||
|
||
## 마주친 문제
|
||
|
||
아직 없음.
|
||
|
||
## 묶음 (이 branch에서 파생된 자료)
|
||
|
||
<!-- GENERATED: branches:start -->
|
||
<!-- GENERATED: branches:end -->
|
||
|
||
## 관련 일일 노트
|
||
|
||
해당 없음.
|
||
|
||
## 완료 후 정리
|
||
|
||
- PR 링크:
|
||
- 리뷰 메모:
|
||
- 머지 결과 / 배포 환경:
|
||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|