콘텐츠로 이동

English · 한국어

호환성 정책: 깨뜨리지 않기로 한 것

이 리포는 계약을 공개하고, 다른 사람들이 그것을 사용합니다. 그래서 깨뜨리지 않기로 약속한 것을 적어 둡니다. 약속을 게이트가 지키게 한 곳에는 그 사실도 적습니다. 점검이 없는 규칙은 언젠가 어긋납니다.

§ 표기: 이 문서에 나오는 §N은 이 문서의 절 번호입니다. 다른 문서를 가리킬 때는 링크로 적습니다.

호환성은 하나가 아니라 다섯 면입니다. 나누지 않으면 「호환된다」는 말이 구체적인 뜻을 잃습니다.

면 무엇이 깨지는가 무엇이 지키는가
① 계약(proto) 새 코드가 옛 결과를 읽지 못합니다 buf breaking(CI)
② 서명 옛 결과의 서명이 무효가 됩니다 TestCanonicalCoversAllFields
③ Go API 사용하는 쪽 코드가 컴파일되지 않습니다 아래 §3의 규칙
④ DB 스키마 새 코드가 옛 DB에서 실패합니다 멱등 DDL + §4
⑤ 버전 혼재 옛 바이너리가 새 DB에 잘못 씁니다 §5: 선택 단계인 DEFAULT 제거 뒤에는 조직 없는 쓰기가 실패합니다

1. 계약: 더하기만 한다

필드와 enum 값은 더하기만 합니다. 번호를 다시 매기거나, 타입을 바꾸거나, 지우지 않습니다. 이름도 바꾸지 않습니다 (선 위에서는 번호만 오가지만, 생성된 코드의 심볼이 바뀌어 사용하는 쪽이 깨집니다).

CI는 직전 태그를 기준으로 buf breaking을 돌립니다. 이것은 규칙이 아니라 게이트입니다.

주석도 예외가 아닙니다. 주석만 바꿔도 gen/이 바뀌므로 함께 커밋합니다. make generate를 돌린 뒤 차이가 남으면 CI가 실패합니다.

2. 서명: 범위를 바꾸면 과거가 무효가 된다

sign.Canonical은 서명 필드를 뺀 모든 필드를 덮습니다. 계약에 필드를 더하면 여기에도 반영해야 하고 (TestCanonicalCoversAllFields가 필드 수를 지켜봅니다), 반영하는 순간 기존 서명이 모두 무효가 됩니다.

그래서 필드는 서명 이전(migration)을 받아들인 릴리스에서만 더합니다. 값을 채우는 것은 다릅니다. Canonical은 필드의 집합이 아니라 값을 읽으므로, 비어 있던 필드를 채우기 시작해도 과거 결과는 같은 바이트로 정규화됩니다(v0.1.3(영문)이 그런 경우였습니다).

3. Go API: 지우지 말고, 바꾸지 말고, 더한다

이 모듈은 go get으로 사용됩니다. 다음 네 가지를 지킵니다.

① 함수 시그니처를 바꾸지 않습니다. 인자가 더 필요하면 옵션 구조체를 받는 새 함수를 만들고 기존 함수는 그것을 호출하는 껍데기로 남깁니다.

func IngestResults(...) (*IngestReport, error)   // unchanged: calls the one below internally
func IngestWith(results []*discoveryv1.CollectionResult, o IngestOptions) (*IngestReport, error)

② 공개 인터페이스에 메서드를 더하지 않습니다. 바깥의 모든 구현이 깨집니다. 필요하면 별도 인터페이스를 만들고, 호출하는 쪽이 타입 단언이나 옵션으로 받게 합니다.

type Store interface{ ... }          // untouched
type RejectionStore interface{ ... } // new: PgStore satisfies it as well

③ 생성자를 더합니다. NewPgStore(ctx, dsn)는 두고 NewPgStoreIn(ctx, dsn, org)를 더합니다. 기존 호출자는 한 줄도 바꾸지 않습니다.

인터페이스로 받은 값에 새 기능이 있는지 물어야 한다면 작은 인터페이스를 하나 더 만들고 타입 단언을 씁니다. org.Scoped가 그 예입니다. history.Store에 Org()를 더하지 않고도 조직을 물어볼 수 있습니다.

④ 모듈 경로는 위 세 가지 밖입니다. 바꾸면 새 모듈이 됩니다. 시그니처를 하나도 건드리지 않아도 경로를 바꾸면 사용하는 쪽의 모든 import가 깨집니다. 그래서 더하는 것으로는 흡수할 수 없는 유일한 경우이고, 방법은 마이너 릴리스에 실어 무엇이 어떻게 바뀌는지 적어 두는 것뿐입니다.

v0.5.0에서 한 번 했습니다: github.com/pqcota/pqcota → github.com/randyinthedev-hash/pqcota. 선언한 경로에 리포가 없어 go get이 아예 가져오지 못했고, 사용하는 쪽은 자기 go.mod에 replace를 계속 들고 있어야 했습니다. 그 뒤로 경로가 리포 주소와 같아져 같은 이유는 다시 생길 수 없습니다.

경로는 리포를 다섯으로 나눌 때 한 번 더 바뀌었습니다(pqcota-common, pqcota-inventory, pqcota-discovery, pqcota-provisioning, 그리고 통합 리포 pqcota). 이번 이유는 구조였습니다. 계약과 공유 코드가 자기 모듈로 옮겨 가서 github.com/randyinthedev-hash/pqcota/gen/..., .../pkg/kernel/..., .../pkg/org는 github.com/randyinthedev-hash/pqcota-common/...이 되었고, 인벤토리 패키지는 .../pqcota-inventory/...가 되었으며, 관측과 전환물 생성도 같은 식입니다. 분리를 실은 릴리스에 대응표가 있습니다. 선 위의 형식은 바뀌지 않았습니다. 각 proto 파일의 go_package 옵션만 바뀌었고, 직전 릴리스와 buf breaking을 돌려도 다른 차이는 나오지 않습니다. 다시 나누는 것은 이번만큼 신중하게 정해야 합니다. 모듈 경로 하나하나가 그것을 import하는 사람에 대한 약속이기 때문입니다.

4. DB 스키마: 멱등하게, 절반만 적용되지 않게

스키마는 CREATE TABLE IF NOT EXISTS + ALTER TABLE ... ADD COLUMN IF NOT EXISTS로 자랍니다. 새 코드가 옛 DB를 만나면 그 자리에서 따라잡습니다.

멱등하지 않은 것이 하나 있습니다. PRIMARY KEY를 바꾸는 일입니다. ADD PRIMARY KEY에는 IF NOT EXISTS가 없고, PK가 바뀌지 않은 DB에 새 ON CONFLICT 절을 돌리면 실행 시점에 오류로 멈춥니다. 이런 이전은 pg_index에서 PK가 지금 몇 개 컬럼인지 확인해 조건부로 실행하는 DO $$ ... $$ 블록으로 씁니다. 두 번 돌려도 결과가 같아야 합니다. 실제 Postgres에 두 번 돌려 본 뒤에만 더합니다.

스키마 자동 생성은 기본으로 켜져 있습니다. PQCOTA_AUTO_DDL=0은 이력 스키마의 자동 생성만 끄며, 끈 상태에서 이력 스키마가 없으면 오류로 멈춥니다. 알리지 않고 CREATE TABLE을 실행하는 생성자는 편하지만, 엉뚱한 곳을 가리키면 새 빈 테이블을 만들어 거기에 씁니다. 그러면 데이터가 사라진 것처럼 보입니다. 그래서 끄는 방법을 두었고, 끈 상태에서는 만들지 않고 확인합니다. 메타데이터와 프로비저닝 기록 테이블은 그래도 만듭니다.

5. 버전 혼재: DEFAULT를 제거하면 조직을 모르는 옛 바이너리의 쓰기가 실패한다

가장 놓치기 쉬운 면입니다. 스키마를 잘 이전했다면 옛 바이너리가 새 DB에 계속 쓸 수 있습니다. DEFAULT가 새 컬럼을 채우기 때문입니다. 편해 보이지만 이것이 함정입니다. 옛 바이너리는 조직을 모르므로 아무 오류 없이 다른 조직의 자리에 씁니다.

그래서 조직 격리가 필요한 배포에서는 마지막 이전 단계로 기본값을 없앱니다.

ALTER TABLE pqcota_snapshots ALTER COLUMN org DROP DEFAULT;

그 뒤로 조직을 모르는 옛 바이너리의 INSERT는 NOT NULL 위반으로 실패합니다. 아무도 모르게 번지는 오염 대신 바로 드러나는 실패입니다. 종료 코드 0, 완전성 지도, 잘림 기록에서 이 리포가 지켜 온 선택과 같습니다. 알리지 않고 빠진 것은 「없다」로 읽힙니다.

조직 하나만 쓰는 사용자는 이 단계를 밟지 않습니다. 기본값이 남고 아무것도 달라지지 않습니다.


이 정책이 적용된 곳

릴리스 지킨 것
v0.1.3 비어 있던 collected_at을 채웠습니다. 값을 채우는 일이므로 과거 서명이 유효하게 남습니다(§2). 시계를 패키지 변수로 바꿔 함수 시그니처는 하나도 바뀌지 않았습니다(§3①)
v0.2.0 새 생성자로 조직 축을 더했고(§3③) 인터페이스를 건드리지 않았습니다(§3②). PK 변경에만 조건부 DDL을 썼고(§4), 기본값 제거는 선택 단계입니다(§5)
v0.5.0 모듈 경로를 리포 주소에 맞췄습니다(§3④). 더하는 것으로는 흡수할 수 없는 유일한 종류라서, 무엇이 바뀌는지 적어 마이너 릴리스에 넣었습니다
분리 계약과 단계를 자기 모듈로 옮겼습니다(§3④). 선 위의 형식과 서명은 그대로이므로 §1과 §2는 건드리지 않았습니다. 사용하는 쪽은 import를 바꾸고 새 모듈을 require합니다
English