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
520 changes: 520 additions & 0 deletions docs/workbench/specs/scale-redesign-list-axis-sharding/design.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Milestones — scale-redesign-list-axis-sharding

Source: design.md (approved + multi-agent reviewed). Full autopilot. All done.
Evidence: full suite 672 passed; new/changed files ruff-clean (advisory).

## M1 공유 샤드 코어 추출 — done
- `axis_core.py` (axis_shard/bucket_width/axis_material); repo_axis.py re-exports
as back-compat aliases. Evidence: #23 repo-axis tests stay green (74 passed).

## M2 list_axis.py — done
- ListAxisSpec (index_name/gsi_pk_field/gsi_sk_field + version/shard/count attrs),
4 axis constants (TARGET_LIST/REPO_LIST/SCAN_DATE=8, SCAN_JOB=4), ListAxisKey,
list_axis_inputs (entity guards). Evidence: test_list_axis_sharding (10).

## M3 list_axis_reader.py — done
- read_list_axis (flat, +parallel for SCAN_JOB) + read_list_axis_ordered (k-way
merge, dedupe-aware over-fetch). fail-closed, spec-parameterized attrs.
Evidence: test_list_axis_reader (fan-out/zero-legacy/dedupe/fail-closed/
ordered top-limit/limit=None/limit>total/ordered-include-legacy).

## M4 write 경로 전환 — done
- scan_target/repo_metadata/scan_run_summary/scan_job mappers → sharded
projection (scan_job pending/leased only; cold stay plain). regression scan
allows list_axis.py as the list-axis #SHARD# source. Evidence: mapper-shape
tests updated to sharded format.

## M5 read 경로 전환 — done
- list_scan_targets / read_recent_repo_metadata (ordered) /
read_scan_runs_for_date / _read_scan_jobs_by_status (parallel pending/leased)
→ fan-out readers; include_legacy params. TODO removed. Evidence: store-level
parity tests updated to fan-out (660 passed).

## M6 list_axis_migration.py — done
- per-axis inventory/backfill/gate (4 GSI1 axes; SCAN_JOB status filter excludes
completed/dead_letter). Evidence: test_list_axis_migration (in-place backfill,
gate_clear, status filter, idempotent re-run, inventory no-mutate).

## M7 dead-write GSI 키 제거 — done
- dropped gsi2pk/gsi2sk from ghas_alert_to_item & secret_evidence_to_item (GHAS
GSI1 repo-axis + secret-evidence gsi1 link fallback preserved); dropped gsi1pk
from ref_state_to_item & repo_lease_to_item; removed unused GHAS_ALERT_LIST_PK.
Updated existing positive assertion (test_incremental_scan_storage.py:284).
Evidence: test_dead_write_gsi_keys (4) + updated round-trip test.

## Operability addition (beyond documented M1–M7, #37 precedent)
- `security-scanner backfill-list-axis [--dry-run]` CLI (cli/commands/migrate.py)
so the migration is runnable on the host. Evidence: test_cli_backfill_repo_axis
list-axis cases (dry-run/apply/backend-guard) + registration-order lock updated.

## Deferred (environment-impossible only)
- Live `backfill-list-axis` run against the Tailscale Ubuntu host DynamoDB-local:
no reachable endpoint from this session. Mechanics + CLI + tests verified here;
operator runs `--dry-run` then apply on the host.
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 요구사항 — 리스트/인덱스 GSI 파티션 클라우드-스케일 핫파티션 재설계 (issue #23 후속)

이 문서는 `design.md`의 승인된 요구사항(approved requirements) 목록이다. 설계 세부는 `design.md`를 따르며, 여기서는 in-scope 표면, 계약, 명시적 제외 항목만 고정한다.

## In-scope 표면

본 작업의 in-scope는 두 갈래다: (A) GSI1 list-axis 4개 샤딩, (B) never-read dead-write GSI 키 4개 제거. 확정 결정은 `design.md`의 "확정 결정 (자문자답)" 절(D1–D5)을 따른다.

### A. 샤딩 적용 대상 (GSI1 list-axis 4개)

단일 정적값을 GSI1 partition key로 쓰는 핫파티션을 #23 `RepoAxisKey` 패턴을 일반화한 list-axis 샤드 헬퍼로 분산한다.

| Axis | 인덱스 | hotness | shard count | read 변경 |
|---|---|---|---|---|
| TARGET_LIST#ALL | GSI1 (`gsi1pk`/`gsi1sk`) | write+read | 8 | `list_scan_targets` → flat scatter |
| REPO_LIST#ALL | GSI1 (`gsi1pk`/`gsi1sk`) | write+read | 8 | `read_recent_repo_metadata` → ordered k-way merge + limit |
| SCAN_DATE#<date> | GSI1 (`gsi1pk`/`gsi1sk`) | write+read | 8 | `read_scan_runs_for_date` → flat scatter |
| SCAN_JOB_STATUS#pending, #leased | GSI1 (`gsi1pk`/`gsi1sk`) | write+read | 4 (확정, D1) | `_read_scan_jobs_by_status` → flat scatter (pending/leased만), pending/leased fan-out 병렬 실행 |

### B. dead-write GSI 키 제거 (샤딩 아님)

never-read GSI projection은 샤딩 이득이 0이고 write amplification만 만든다. store.py 검증 결과 아래 4개 키는 어떤 GSI 경로로도 읽히지 않으므로 write mapper에서 제거한다(D3·D4).

| Surface | 제거 키 | write mapper | rationale |
|---|---|---|---|
| GHAS_ALERT#ALL | `gsi2pk`/`gsi2sk` | `ghas_alert_to_item` | GSI2 never-read → projection 제거로 hot partition + write amplification 동시 제거. GSI1 repo-axis projection(#23)은 보존. |
| SECRET_EVIDENCE#ALL | `gsi2pk`/`gsi2sk` | `secret_evidence_to_item` | GSI2 never-read → projection 제거. `_secret_evidence_link_pk`의 gsi1 fallback은 불변. |
| REF_STATE#ALL | `gsi1pk` | `ref_state_to_item` | GSI1 never-read → projection 제거. base-table read(point get / `PK=REPO#` query)는 영향 없음. |
| REPO_LEASE#ALL | `gsi1pk` | `repo_lease_to_item` | GSI1 never-read → projection 제거. base-table read는 영향 없음. |

## 승인된 계약 (approved contracts)

1. **공유 코어 재사용 + back-compat**: 샤드 해시(`axis_shard`)·material(`axis_material`)·bucket width(`bucket_width`)는 `repo_axis.py`에서 추출한 단일 구현을 repo-axis와 list-axis가 공유한다. `repo_axis.py`는 기존 이름을 alias로 보존해 #23 import/테스트가 깨지지 않는다.

2. **durable shard-count = 스키마 계약 (런타임 knob 아님)**: 각 axis의 `<axis>Version=1`이 그 `shardCount`를 고정한다. 샤드 수 변경은 새 version(`=2`) + 재해시 마이그레이션(또는 active-version fan-out)을 요구한다. shard count는 환경변수/런타임 설정으로 바꾸지 않는다.

3. **멀티-인덱스 정확성 (spec 파라미터화)**: `ListAxisSpec`은 `index_name`, `gsi_pk_field`, `gsi_sk_field`를 명시 보유하고 reader는 이 spec 필드만 사용한다(generic `gsi_pk`/`gsi_sk` placeholder 금지). 현재 활성 샤드 axis 4개는 모두 GSI1(`index_name=GSI1_NAME`, `gsi1pk`/`gsi1sk`)이지만, spec 파라미터화로 미래에 GSI2 axis가 추가되어도 reader 코드 변경 없이 라우팅된다. (GHAS_ALERT/SECRET_EVIDENCE는 GSI2 never-read이므로 샤딩하지 않고 키를 제거한다 — D3.)

4. **ordered/limited read 보존**: `read_recent_repo_metadata(limit)`의 `ScanIndexForward=False` + limit 계약을 보존한다. per-shard 내림차순 + k-way descending merge로 전역 newest-first 상위 limit개를 정확히 재구성한다. dedupe가 개입하는 `include_legacy=True` 윈도에서는 early-`Limit` 최적화를 끄고 full-prefix over-fetch 후 dedupe→truncate해 전역 top-limit 정확성을 보존한다(중복으로 인한 short/wrong-tail 금지).

5. **fail-closed scatter-gather**: 임의 샤드 쿼리 실패는 전파한다. 부분 결과가 완전 결과처럼 보여서는 안 된다(직렬·병렬 fan-out 모두).

6. **migration-only legacy fallback + in-place backfill gate**: legacy 정적 파티션은 `include_legacy=True` 뒤에서만 읽는다(기본 `False`). backfill은 같은 primary key 위에서 `update_item`(condition `attribute_not_exists(<version_attr>)`)으로 in-place 수행하며 행을 새 키로 복사하지 않는다. legacy read 경로 제거는 **per-axis remaining==0** + parity/zero-legacy-query 테스트 gate 충족 시에만 허용한다. legacy 식별 predicate는 `item[spec.gsi_pk_field]`(axis별 올바른 GSI)만 읽는다. 이 backfill/gate는 GSI1 list-axis 4개에만 적용된다(dead-write 키 제거는 backfill 대상이 아님).

7. **lease fan-out 확정 (D1)**: `lease_next_scan_job`은 lease 시도당 2N(=N pending + N leased) GSI 쿼리를 발행한다. 확정 결정은 SCAN_JOB shard_count=4 + pending/leased fan-out **병렬 실행(thread pool)을 본 작업에 포함**. fail-closed는 병렬 fan-out에서도 유지(한 future 실패도 전파). go-live 측정 후 부족하면 `scanJobAxisVersion=2` rehash로 16+ 튜닝하는 follow-up이며 본 spec의 blocker가 아니다. "측정 없이 prod 발견" 금지.

## 명시적 제외 (out-of-scope / 비샤딩 retained)

- **SCAN_JOB_STATUS#completed, #dead_letter**: GSI로 읽히지 않는 cold 파티션 — plain pk 유지(version_attr 없음). migration predicate가 status로 inventory·backfill 양쪽에서 제외.
- **`_secret_evidence_link_pk` (gsi1pk catch-all = `SECRET_EVIDENCE#ALL`)**: linked finding/alert가 없을 때 쓰는 gsi1 catch-all 버킷. 제거하는 SECRET_EVIDENCE GSI2 키(`gsi2pk`)와 무관한 별도 access pattern이라 **건드리지 않음**(D3).
- **`RULE#<rule_id>` / `REPO#<repo_id>` gsi2 projection** (`finding`·`observation`·`state`·`state_event`·`scan_job` mappers): 현재 GSI2 reader 0건이라 엄밀히 never-read이나, 단일값 list-ALL 핫버킷이 아니라 key별 **분산**이고 "rule별 finding/repo별 job 조회"라는 **예약 access pattern**이라 **보존**한다. dead-write 제거 기준 = never-read ∧ 단일값 list-ALL ∧ 예약 access pattern 없음(세 조건 동시) — 이들은 미충족. 향후 read 미생성 시 write-amp 절감을 위해 별도 follow-up으로 제거 재검토(review LOW #3·#6·#7).
- **rollout / canary**: 본 작업 범위 밖(#23과 동일). legacy 상수/`include_legacy` 분기 삭제는 gate 충족 후 별도 정리 작업.
- **소스 코드 변경**: 본 spec은 설계 전용. 구현은 별도 단계.

> REPO_LEASE#ALL(gsi1)·REF_STATE#ALL(gsi1)은 더 이상 out-of-scope가 아니다. never-read dead-write 키로 확정되어 **in-scope 제거 대상**(위 "B. dead-write GSI 키 제거")으로 옮겼다(D4). REF_STATE는 `REPO#` base-table PK를 공유하지만 repo-axis/list-axis 어디에도 참여하지 않으며, `gsi1pk=REF_STATE#ALL` 제거 후에도 base-table 접근(point get / `PK=REPO#` query)은 영향 없다.

## Follow-up (별도 issue)

- **D2 — 워커 폴링 backoff**: 샤딩으로 scan-job read CU가 (4×) 증가한다(throttle 회피 trade-off는 옳음). job 미발견 시 워커 exponential backoff는 worker 런타임 정책이므로 schema 샤딩과 분리해 별도 follow-up issue로 다룬다(read CU 증가가 실재하므로 강력 권장).
- **D5 — get_queue_status full-table scan**: `get_queue_status`의 base-table `scan_all_pages`는 GSI 핫파티션과 독립된 full-scan 스케일 문제 — 별도 issue.

## 열린 질문

없음. 직전 초안의 모든 열린 질문은 `design.md`의 "확정 결정 (자문자답)"(D1–D5)에서 확정했다. D1 튜닝(16+ rehash)·D2·D5는 측정/별도 issue로 위임한다.
52 changes: 52 additions & 0 deletions src/security_scanner/cli/commands/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

from security_scanner.cli._args import add_storage_args
from security_scanner.cli._store import dynamodb_config_from_args
from security_scanner.storage.adapters.nosql_db.list_axis_migration import (
backfill_list_axis,
inventory_legacy_list_axis,
)
from security_scanner.storage.adapters.nosql_db.repo_axis_migration import (
backfill_repo_axis,
inventory_legacy_repo_axis,
Expand All @@ -38,6 +42,18 @@ def register(subparsers) -> None:
)
parser.set_defaults(func=cmd_backfill_repo_axis)

list_parser = subparsers.add_parser(
"backfill-list-axis",
help="Migrate legacy list/index GSI rows to the sharded layout (#23).",
)
add_storage_args(list_parser, include_jsonl_path="", default_backend="dynamodb")
list_parser.add_argument(
"--dry-run",
action="store_true",
help="Report legacy inventory per axis without mutating anything.",
)
list_parser.set_defaults(func=cmd_backfill_list_axis)


def _table_from_args(args: argparse.Namespace):
config = dynamodb_config_from_args(args)
Expand Down Expand Up @@ -79,3 +95,39 @@ def cmd_backfill_repo_axis(args: argparse.Namespace) -> int:
return 0
print("gate: NOT CLEAR (legacy rows remain or failures occurred)")
return 1


def cmd_backfill_list_axis(args: argparse.Namespace) -> int:
"""Backfill (or inventory) legacy list-axis GSI rows for the dynamodb store."""
if args.storage_backend != "dynamodb":
print(
"backfill-list-axis requires --storage-backend dynamodb "
f"(got '{args.storage_backend}')",
file=sys.stderr,
)
return 2

table = _table_from_args(args)

if args.dry_run:
inventory = inventory_legacy_list_axis(table)
print("list-axis legacy inventory (dry-run, no mutation):")
for axis, count in inventory.items():
print(f" {axis}: {count}")
print(f" total legacy rows: {sum(inventory.values())}")
return 0

report = backfill_list_axis(table)
print("list-axis backfill report:")
for axis, counts in report.by_axis.items():
print(
f" {axis}: inventory={counts.inventory} "
f"backfilled={counts.backfilled} skipped={counts.skipped} "
f"failed={counts.failed} remaining={counts.remaining}"
)
any_failed = any(counts.failed for counts in report.by_axis.values())
if report.gate_clear and not any_failed:
print("gate: CLEAR (no legacy list-axis rows remain)")
return 0
print("gate: NOT CLEAR (legacy rows remain or failures occurred)")
return 1
40 changes: 40 additions & 0 deletions src/security_scanner/storage/adapters/nosql_db/axis_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Axis-neutral shard primitives shared by repo-axis and list-axis (issue #23).

These three helpers are the common core extracted from ``repo_axis.py`` so the
repo-axis (#23) and list-axis (scale redesign) sharding families derive identical
buckets from one implementation. This module imports nothing from the package
(only ``hashlib``), so every other sharding module can depend on it without an
import cycle.
"""

from __future__ import annotations

import hashlib


def bucket_width(shard_count: int) -> int:
"""Return the zero-pad width for the largest bucket index."""
return len(str(shard_count - 1))


def axis_shard(shard_material: str, *, shard_count: int) -> str:
"""Return the fixed-width deterministic shard bucket for stable material.

The bucket is a SHA-256 digest of ``shard_material`` modulo ``shard_count``,
so the same logical row always lands in the same partition regardless of
process or host.
"""
if shard_count <= 0:
raise ValueError("shard_count must be positive")
digest = hashlib.sha256(shard_material.encode("utf-8")).hexdigest()
bucket = int(digest, 16) % shard_count
return f"{bucket:0{bucket_width(shard_count)}d}"


def axis_material(*parts: str) -> str:
"""Join stable shard inputs into one deterministic material string.

Uses a NUL separator so distinct field boundaries cannot collide (e.g.
``("a", "bc")`` differs from ``("ab", "c")``).
"""
return "\0".join(parts)
Loading
Loading