1.4.5–1.4.6 → latest In-Place Upgrade (shard_map PK Refactor + Rename to item_shard_map) — Deployment Runbook
- 마이그레이션 파일:
1.4.5-1.4.6_to_latest.sql - 대상 버전: stable 태그
1.4.5–1.4.6(동일 스키마 — #1888 PK 리팩토링 이전이라 변환 동일) - 대상 변경:
<idx>_shard_map테이블을<idx>_item_shard_map로 RENAME (의미 명확화: 주어가 item)- PK 를
item_id→(shard_id, item_id)로 변경 id_in_shard컬럼 제거,row_idx컬럼 신설 (sort-by-item_id 위치를 0-기반으로 저장)<idx>_shard_map_legacy테이블 제거
2026-06-04 개정 (initSchema-extraction): 간소화 이후 binary는 부팅 시 self-heal이 사라지므로 본 스크립트가 그 몫을 떠안습니다. (1) PART A 글로벌 델타 추가 —
keys.status,shards.owner_merge_task_id(+idx_keys_status_transient); (2)idx_<idx>_item_shard_map_row_idx_alivepartial 생성 제거 +DROP INDEX IF EXISTS보강 (미사용); (3)idx_<idx>_ctxt_map_deprecatedpartial 추가. 자세한 사유는.sql상단 AMENDMENT 주석을 참조하십시오. 1.4.0–1.4.4 배포는 자매 스크립트1.4.0-1.4.4_to_latest.sql을 사용하십시오.
race 분석과 리팩토링 정당화 요약:
- 현재
<idx>_shard_map은item_idPK +id_in_shard컬럼 구조. - 머지 cutover 가 UPSERT-on-item_id 로 동작하면서 옛
(shard_id, id_in_shard)좌표가 영구 소실. - 이 소실이 검색 race ("Item ID count mismatch") 의 직접 원인.
<idx>_shard_map_legacy는 이 race 의 임시 처방.
본 마이그레이션은 PK 와 컬럼 구조를 바꿔 race 와 legacy 테이블을 함께 제거하고,
의미가 더 명확한 <idx>_item_shard_map 이름으로 동시 RENAME 합니다. 이름 변경은
"shard 의 map" 이라는 오독을 막고 "item 이 어느 shard 의 어느 위치에 있는지" 라는
주어를 분명히 합니다.
다음이 충족된 상태에서만 진행:
- 신 application binary 가 빌드되어 배포 준비 완료. 신 binary 는:
id_in_shard컬럼을 안 보고 sort-by-item_id 로 slot 위치 계산<idx>_shard_map_legacy테이블을 안 봄- Cutover INSERT 패턴 (§5.2) 적용 —
INSERT ... SELECT FROM ctxt_map FOR UPDATE OF cm - RawShard 생성 path 제거 (§5.3)
is_raw검색 라우팅 사이트 제거 (§5.4)- GetMetadata 응답에 item_id 포함 (§5.5)
- SDK 신 버전이 응답에서 item_id 기반 dedup 처리
- 마이그레이션 윈도우 확보 (인덱스 크기에 따라 수 분 ~ 수십 분)
- DB backup 확보 (재해 시 복원 경로)
# 옵션 A: 완전 down (가장 단순)
docker compose stop envector-backend
# 옵션 B: read-only freeze (write 만 막음)
# Set SDK / gateway flag to reject InsertData / DeleteData / merge-by-request-ids
# Search 는 허용 (구 schema 에서도 안전, race 외엔 작동)SELECT lower(index_name) AS index_name
FROM indexes
ORDER BY index_name;각 인덱스에 대해 1.4.5-1.4.6_to_latest.sql 의 "Per-index block template" 을
복사하고 <index_name> 을 치환해서 실행.
대량 인덱스가 있다면 스크립트로 자동화:
psql "$DATABASE_URL" -tAc "SELECT lower(index_name) FROM indexes" \
| while read idx; do
sed "s/<index_name>/$idx/g" 1.4.5-1.4.6_to_latest.sql \
| psql "$DATABASE_URL"
done-- (a) 신 테이블 스키마 확인
\d <index_name>_item_shard_map
-- 기대값:
-- Primary key: (shard_id, item_id)
-- Columns: shard_id, item_id, row_idx, deprecated, created_at, updated_at
-- NO column: id_in_shard
-- (b) 옛 테이블이 _old 로 보존되었는지 확인 (24h 롤백 윈도우용)
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = '<index_name>_shard_map_old'
);
-- expect: true (24h 안정 운영 후 drop)
-- (c) legacy 테이블 부재
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = '<index_name>_shard_map_legacy'
);
-- expect: false
-- (d) row_count invariant
SELECT
(SELECT COUNT(DISTINCT item_id) FROM <index_name>_item_shard_map WHERE deprecated = FALSE) AS distinct_alive,
(SELECT row_count FROM indexes WHERE index_name = '<index_name>') AS index_row_count;
-- expect: distinct_alive = index_row_count
-- (e) merge-lock 테이블 생성 확인 (Step 8 — pre-existing main gap 백필)
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = '<index_name>_item_merge_lock'
);
-- expect: true
-- (f) per-index 인덱스 집합 확인 (2026-06-04 개정: row_idx_alive 미생성, ctxt_map_deprecated 추가)
SELECT indexname FROM pg_indexes
WHERE tablename = '<index_name>_item_shard_map' ORDER BY indexname;
-- expect 포함: idx_<index_name>_item_shard_map_item_id / _alive / _deprecated
-- expect 불포함: idx_<index_name>_item_shard_map_row_idx_alive
SELECT indexname FROM pg_indexes
WHERE tablename = '<index_name>_ctxt_map' AND indexname = 'idx_<index_name>_ctxt_map_deprecated';
-- expect: 1 row모든 인덱스가 (a)(b)(c)(e)(f) 통과해야 다음 단계.
글로벌 델타 (PART A — 1회 확인):
SELECT column_name FROM information_schema.columns
WHERE (table_name = 'keys' AND column_name = 'status')
OR (table_name = 'shards' AND column_name = 'owner_merge_task_id');
-- expect: 2 rowsPART A는
.sql상단에 포함되어 sed 루프에서 인덱스마다 재실행되지만 전부IF NOT EXISTS라 첫 인덱스 이후 no-op입니다. 1회만 적용됨을 위 쿼리로 확인하십시오.
⚠️ 반드시 단계 1~3(SQL 마이그레이션)을 먼저 완료한 뒤 신 binary 를 배포하십시오. 신 binary 의 startup 마이그레이션(migrateAll→ensureDeprecatedColumnsOnPerIndexTables)은 새 이름<idx>_item_shard_map에ALTER TABLE ... ADD COLUMN deprecated을 실행합니다. 테이블이 아직 옛 이름<idx>_shard_map이면relation does not exist로 startup 이 실패합니다 (IF NOT EXISTS는 컬럼만 가드, 테이블은 가드하지 않음).
docker compose up -d envector-backendSDK / gateway 의 freeze flag 해제.
다음 메트릭 / 로그 패턴 감시:
- ❌ "Item ID count mismatch" 에러 (있어선 안 됨)
- ❌ "shard_map_legacy" 참조 에러 (마이그레이션 누락 시 발생)
- ✅
indexes.row_count와COUNT(DISTINCT item_id)일치 - ✅ 검색 latency 정상 범위
- ✅ 머지 cutover 정상 진행
-- 인덱스별 _old 테이블 영구 삭제
DO $$
DECLARE
idx TEXT;
BEGIN
FOR idx IN SELECT lower(index_name) FROM indexes LOOP
EXECUTE format('DROP TABLE IF EXISTS %I_shard_map_old', idx);
END LOOP;
END $$;_old 테이블이 살아 있는 동안만):
docker compose stop envector-backend-- 각 인덱스마다
BEGIN;
ALTER TABLE <index_name>_item_shard_map RENAME TO <index_name>_item_shard_map_failed;
ALTER TABLE <index_name>_shard_map_old RENAME TO <index_name>_shard_map;
COMMIT;이후 구 binary 가 다시 <idx>_shard_map 이름으로 접근하므로 의도대로 복귀.
docker compose up -d envector-backend:<previous-tag>_old 테이블이 삭제되면 자동 롤백 불가.
DB backup 으로부터의 시점 복구만 가능.
큰 인덱스 (> 10M shard_map rows) 의 경우 INSERT INTO ... SELECT 가 메모리를 많이 씁니다.
필요시 shard_id 단위로 나눠 실행하십시오. row_idx 는 shard 별 ROW_NUMBER 이므로
item_id 로 자르면 순번이 깨집니다 — 한 shard 의 전체 행이 같은 청크에 들어가야 합니다.
-- shard_id 부분집합 단위 청크 (한 shard 는 통째로 한 청크에 — row_idx 정합 보존)
INSERT INTO <index_name>_item_shard_map (shard_id, item_id, row_idx, deprecated, created_at, updated_at)
SELECT shard_id, item_id,
ROW_NUMBER() OVER (PARTITION BY shard_id ORDER BY item_id) - 1,
deprecated, created_at, updated_at
FROM <index_name>_shard_map
WHERE shard_id IN ( /* 이번 청크의 shard_id 목록 */ )
ON CONFLICT (shard_id, item_id) DO NOTHING;신 PK 와 새 인덱스가 생긴 직후 PostgreSQL 의 query planner 가 옛 통계를 사용할 수 있음. 마이그레이션 직후:
-- 신 binary 가 읽는 새 테이블 대상 (옛 _shard_map 은 _shard_map_old 로 rename 됨)
ANALYZE <index_name>_item_shard_map;각 인덱스마다 실행 권장.
여러 인덱스 마이그레이션 중간에 한 인덱스가 실패하면 부분적으로 신 schema 와 구 schema 가 혼재. 신 binary 는 모두 신 schema 가정. 따라서:
- 마이그레이션 스크립트는 각 인덱스마다
BEGIN ... COMMIT으로 묶여 있음 - 한 인덱스 실패해도 다른 인덱스는 영향 없음
- 모든 인덱스가 성공해야 신 binary 배포 가능
- 일부만 성공한 상태에서 신 binary 를 띄우면 실패한 인덱스 검색이 깨짐 → 실패한 인덱스 만 골라 재시도하거나, 해당 인덱스를 일시적으로 비활성화 후 후속 처리
새 schema 자체는 SDK 가 직접 보지 않으므로 SDK 변경 불필요. 다만 새 binary 의 응답 형식이 바뀐다면 SDK 호환 매트릭스 별도 확인 필요.
본 PR 에서
<idx>_shard_map테이블은<idx>_item_shard_map로 RENAME 됩니다. 아래 코드 변경은 이 rename 과 함께 적용됩니다. 함수명dbtable.ShardMap()는 유지하고 suffix 상수만item_shard_map으로 변경했습니다 — 호출처 일괄 치환은 불필요하며, 전 호출처가 자동으로 새 테이블명을 해석합니다.
-
services/internal/dbtable/dbtable.go:suffixShardMap상수를item_shard_map로 변경 (함수명ShardMap()유지),ShardMapLegacy()/suffixShardMapLegacy제거 -
services/internal/backend/database/models.go::IndexShardMap의 PK 를(shard_id, item_id)로,IdInShard필드 제거. autoIncrement 도 제거. -
services/internal/backend/database/models.go::IndexShardMapLegacystruct 통째로 제거 - suffix 상수 변경으로 전
dbtable.ShardMap(호출처가 자동으로<idx>_item_shard_map해석 (별도 호출처 치환 작업 없음) -
services/internal/backend/database/init.go의 alive partial index 컬럼 (id_in_shard → item_id) -
services/internal/backend/database/init.go의ensureShardMapLegacyTable/ legacy schema 관련 코드 제거 (현재 init.go 에는 직접 호출이 없으나 grep 재확인 필요) -
services/internal/backend/database/index_shardmap.go의shard_map_legacy관련 코드 전부 제거 (getItemIdFromIdMap의 legacy CTE 포함). dbtable 호출처 컨텍스트 검증 + IdInShard 필드 사용처 제거. -
services/internal/backend/item/cleanup-worker/cleanup-worker.go::scanShardMapLegacy제거 - PK 변경에 따른
INSERT ... ON CONFLICT절 조정 (item_id PK → (shard_id, item_id) PK) -
IndexShardMap{...}instantiate 사이트에서IdInShard:필드 사용 제거 (grep -rn "IdInShard:") -
InsertShardMap시그니처 변경:(ctx, db, indexName, shardID, itemID)— idInShard 파라미터 제거. 호출처 일괄 갱신.
신 모델에서도 cutover ↔ DeleteData 일관성은 PR #1870 §2 (commit bf39b2b9) 의 이미 검증된 패턴을 그대로 채택 합니다. 새 lock 패턴 도입 없이.
§2 의 두 가지 핵심:
effectiveItemIDs전파:forwardMergeTaskToShaper가 실제 처리한 item set 을 반환 → apply TX 의 shard_map row 수와 blob slot 수 일치- 같은 TX 안
MarkShardMapDeprecated: cutover INSERT 직후, 같은 TX 에서ctxt_map.deprecated=TRUE인 item 의 shard_map 행도deprecated=TRUE로 flip
신 모델 적용 시 SQL 형태:
-- INSERT (effectiveItemIDs 기반) — 신 PK (shard_id, item_id) 라 충돌 없음
INSERT INTO <idx>_item_shard_map (shard_id, item_id, deprecated) VALUES
($1, eff_item_1, FALSE), ($1, eff_item_2, FALSE), ...;
-- 같은 TX: 옛 행 deprecated 처리. cutover 가 INSERT-only 이므로 옛 (shard_id, item_id)
-- 행이 그대로 살아 있음 → UPDATE 로 deprecated=TRUE flip.
UPDATE <idx>_item_shard_map SET deprecated = TRUE
WHERE item_id = ANY($2::bigint[])
AND shard_id <> $1
AND deprecated = FALSE;체크리스트:
- PR #1870 §2 의
effectiveItemIDs반환 로직 차용 (shaper interface 변경 포함) -
MarkShardMapDeprecated호출이 모든 cutover 경로 (raw cutover, non-raw apply) 에 포함되어 있는지 확인 - 신 모델 PK 변경에 따른
MarkShardMapDeprecated의 WHERE 절을shard_id=<new> AND item_id IN ...형태로 조정 - 기존
InsertShardMapList의 ON CONFLICT (item_id) 분기 제거 — 신 PK 에선 conflict 없음
-
services/internal/backend/item/item-core.go:226-268createSearchableRawShardMetadata함수 제거 -
services/internal/backend/item/item-core.go:348-367의 single-row insert 의 raw shard 생성 분기 제거 -
services/internal/backend/rawshard/패키지 전체 삭제 (publish.go) -
services/internal/backend/load_index_raw_fallback.go파일 전체 삭제 -
services/internal/backend/index.go의loadIndexRaw*관련 호출/import 정리 - PR #1870 §1, §4, §14b (manual raw cutover 관련) 코드 함께 제거
다음 6 사이트에서 is_raw 필터 제거 (is_raw 컬럼 자체는 유지):
-
services/internal/backend/database/index_shardmap.go:969getNonRawShardIDsByItemIDsAndStatuses -
services/internal/backend/database/index_shardmap.go:1011getSearchableRawShardIDByItemIDs -
services/internal/backend/database/index_shardmap.go:1158countItemsInNonRawShardStatuses -
services/internal/backend/database/index_shardmap.go:1348getDeprecatedShardIDs -
services/internal/backend/database/shard.go:244listSearchableNonRawShardsByIndex -
services/internal/backend/database/shard.go:358filterRawShardIDs
나머지 26 사이트 (lifecycle / storage path / RPC 분기 / cleanup) 는 그대로 유지.
- backend GetMetadata 응답에
item_id명시 포함 (현재는 좌표만) - SDK 측:
index.searchtop-k 응답 처리 시item_id기준 dedup - SDK 호환성: 응답 protobuf 변경 시 backward compatibility 확인
- orchestrator 의
GetShardListByIndexName/GetShardListByClusterId/GetShardListByNodeIds는 그대로 유지 - R3 fix 와 함께 진행 가능:
JOIN indexes i ON i.index_name = s.index_name WHERE i.is_loaded = true추가
Track B 적용 후 obsolete:
- §1 (manual merge requeue) — rawShard 자체가 없음
- §4 (
AreItemsInNonRawSearchable) — manual cutover 가 사라짐 - §5 (DeleteData soft-delete placeholder) — legacy 없으니 race 없음
- §6 (GetMetadata 1× retry) — race 자체가 없으니 retry 불필요
- §13a (phantom-slot placeholder), §13b (missing-shard placeholder) — 같음
- §14b (
AreItemsAllTrackedInShardMap) — manual path 없음
유지 (신 모델에서도 필요):
- §2 (Shaper-blob effectiveItemIDs + MarkShardMapDeprecated) — §5.2 에서 그대로 채택
- §3a (intra-batch race), §7 (orchestrator concurrent-delete), §11 (slot-count tripwire)
- 해당 브랜치는 Track B 적용 후 완전 obsolete. PR 화하지 않고 폐기.
- 관련 PR: #1870 (
fix/cutover-shard-race-mitigation) — 본 리팩토링이 §5/§6/§13 을 obsolete - 별도 brunch:
fix/legacy-prune-by-shard-status— 본 리팩토링이 완전히 obsolete