본문으로 건너뛰기

v2 Export 마이그레이션

본 문서는 SDK 의 export 파이프라인을 v2 백엔드의 bulk-fetch endpoint 로 전환한 작업을 설명합니다. Phase 1 (RuntimeContext.v2_client wiring + failure_classifier v2 error code 지원) 이 main 에 머지된 상태에서, 단일 통합 PR (feat/SYN-6919-sdk-v2-export-migration) 로 cut-over 했습니다.

본 변경은 plugin 작성자 입장에서 additive 입니다 — 기존 v1 surface 는 불변입니다. SYN-7415 부터는 v1/v2 선택 스위치 가 제거되어, 마이그된 caller 는 런타임에 선택하는 대신 고정(fixed) 됩니다 (아래 참고).

SYN-7415 — 선택 스위치 제거, caller 고정

SYN-6919 는 원래 caller 를 런타임 use_v2 스위치 뒤에서 v2 로 라우팅했고, SYN-7082 가 이를 action category 단위로 좁혔습니다. SYN-7415 가 선택 스위치 기계장치를 완전히 제거했습니다 — use_v2 / _V2_CATEGORY_POLICY / SYNAPSE_FORCE_V1_EXPORT 는 더 이상 존재하지 않습니다. 런타임 v1/v2 분기가 없어졌고, 각 마이그된 callsite 는 하나의 client 로 고정됩니다:

  • export/handlers.py → v2-fixed: 항상 ctx.v2_client 의 v2 2-phase 경로 (list → bulk_fetch/bulk_data) 를 수행합니다. v1 fallback 이 없으며, v2_client 미배선 시 명시적 ClientError (EXPORT_FAILED_V2_NOT_WIRED) 가 발생합니다.
  • to_task/steps/fetch_tasks.py → v1-fixed (legacy list_tasks).
  • dataset/action.py _download_split → v1-fixed (legacy list_ground_truth_events).

transport-agnostic 2-phase fetch helper (_collect_then_bulk / _stream_then_bulk / strip_paginator_reserved_keys) 는 _v2_switch 에서 synapse_sdk/plugins/actions/_v2_fetch.py 로 이동했습니다. 기존 _v2_switch 모듈은 기존 import 경로가 계속 동작하도록 backward- compatible re-export shim 으로만 남습니다.

요약​

  • SDK 클라이언트에 4개의 v2 backend endpoint 추가:
    • client.v2_client.tasks.bulk_fetch(ids) → POST /v2/tasks/bulk-fetch/
    • client.v2_client.assignments.bulk_fetch(ids) → POST /v2/assignments/bulk-fetch/
    • client.v2_client.ground_truths.bulk_data(ids) → POST /v2/ground-truths/bulk-data/
    • client.v2_client.ground_truth_events.list(...) + bulk_data(pgh_ids)
  • 마이그된 3개 caller, 이제 고정 (SYN-7415 — 선택 스위치 없음):
    • export/handlers.py (Task / Assignment / GroundTruth handler) → v2-fixed
    • to_task/steps/fetch_tasks.py → v1-fixed
    • dataset/action.py (_download_split) → v1-fixed
  • nested accessor 1개:
    • client.v2_client.ground_truth_datasets.versions(ds).events(ver).list(...)

v1 → v2 caller 패턴​

v2 endpoint 는 기존 단일 호출 fetch 를 2-phase 패턴으로 분리합니다:

Phasev1 (단일 호출)v2 (2-phase)
Listclient.list_tasks(params, list_all=True) — heavy row 반환client.v2_client.tasks.list(..., list_all=True) — slim row 반환
Hydrate(위 호출에 포함)client.v2_client.tasks.bulk_fetch(ids) chunk 별 호출

공용 helper _collect_then_bulk 가 이 패턴을 codify 합니다:

from synapse_sdk.plugins.actions._v2_fetch import _collect_then_bulk

rows = _collect_then_bulk(
list_method=lambda: client.v2_client.tasks.list(project=42, list_all=True),
bulk_method=lambda ids: client.v2_client.tasks.bulk_fetch(ids),
ids_per_batch=200,
)

기본 extractor 는 dict row 와 v2 Pydantic list model 양쪽을 모두 처리합니다. 즉 task / assignment list row 가 {"id": ...} 형태의 dict 로 오거나 TaskV2List / AssignmentV2List 처럼 .id attribute 를 가진 object 로 오더라도, helper 는 동일하게 id 를 수집한 뒤 bulk_fetch 를 호출합니다.

Backend 는 bulk_fetch / bulk_data 1회 호출 당 200 ids 까지 처리합니다 (V2BulkFetchMixin.BULK_FETCH_MAX_IDS — apps/shared/api/viewsets/bulk_fetch.py). Helper 기본값은 이 cap 과 동일한 200 입니다. 더 큰 chunk 를 보내면 backend 가 400 ids_limit_exceeded 로 응답합니다. per-call 압박을 줄여야 하는 경우 더 작은 값을 전달할 수 있지만, 200 을 초과할 수는 없습니다.

ground_truth_events 의 primary key 는 id 가 아닌 pgh_id 입니다. extract_id callable 을 전달하세요. custom extractor 역시 dict/model 양쪽과 호환되게 작성해야 합니다:

def item_field(item, field):
if isinstance(item, dict):
return item[field]
return getattr(item, field)


rows = _collect_then_bulk(
list_method=lambda: client.v2_client.ground_truth_events.list(
ground_truth_dataset_version=99,
list_all=True,
),
bulk_method=lambda pgh_ids: client.v2_client.ground_truth_events.bulk_data(pgh_ids),
extract_id=lambda item: item_field(item, 'pgh_id'),
)

v2 path 의 throttle / retry 계약​

v1 export handler 는 _apply_export_params 를 통해 EXPORT_THROTTLE_SECONDS / EXPORT_PAGE_RETRIES / EXPORT_READ_TIMEOUT 를 cursor paginator 에 전달, page fetch 페이싱을 조절했습니다. v2 path 는 list endpoint cursor pagination (resource layer 가 처리) + bulk-fetch sequence 의 조합이므로 _apply_export_params 의 wholesale 적용이 불가합니다.

마이그된 handler 는 bulk-fetch sequence 의 v1 inter-call throttle 을 보존합니다 — EXPORT_THROTTLE_SECONDS 를 _collect_then_bulk(..., throttle_seconds=...) 로 forward 함. v1 production exporter 에서 검증된 per-handler back-pressure (Task = 0.2s, GroundTruth = 0.1s, Assignment = 0.1s — 첫 호출 이전 / 마지막 호출 이후에는 sleep 없음) 가 그대로 유지됩니다.

EXPORT_PAGE_RETRIES / EXPORT_READ_TIMEOUT 은 v2 client 까지 plumb 되지 않습니다 — v2 transport (BackendClientV2._request) 가 자체 retry + timeout policy 를 적용합니다 (v2 cursor paginator + HTTPX defaults). v2 retry semantics 를 조정하려면 handler 가 아닌 BackendClientV2 생성 위치에서 설정하세요.

다음 진화 — server-side async-job 위임 (SYN-7104 P2)​

v2 2-phase path 는 여전히 SDK (ray job) 가 v2 REST 로 모든 row 를 끌어와 in-process 로 변환합니다. 10만 건 이상 + 원본 파일이 큰 export 에서는 이 끌어오기 자체가 비용·취약성의 핵심입니다. SYN-7104 P2 는 v2 export path 위에 backend async-job 위임 레이어를 얹습니다: 대상 건수가 임계를 넘으면 데이터 취득(acquisition) 을 backend async-job (서버사이드 keyset 스캔 → raw bundle) 에 넘기고, SDK 는 enqueue · 진행률 스트리밍 · bundle 읽기 후 비위임 경로와 동일한 공유 transform tail 을 실행합니다. 위임은 데이터를 어떻게 취득하는가 만 바꿀 뿐 산출물은 동일합니다 (투명 위임, FR-8 parity). 이는 Export Actions 의 future-work 항목으로 추적되던 "(A안) server-side async export-to-storage" 의 첫 도입입니다.

이 위임은 2-phase v2 path 를 대체하지 않고 그 위에 얹히는 추가 레이어입니다. 임계 미만인 export 는 in-process head(v2 list → bulk-fetch) 로, 임계 초과인 export 는 delegated head 로 데이터를 취득합니다. 어느 경로든 취득 후에는 같은 공유 tail(ConvertData → SaveFiles → Finalize) 이 실행되므로 플러그인 커스텀 로직이 양쪽 경로에 동일하게 적용됩니다.

기본 ON · 투명 위임 (staging 검증 권장)

위임 게이트는 기본 활성(ON) 입니다. 위임은 데이터 취득 경로만 서버사이드로 옮길 뿐, 산출물은 비위임 경로와 동일한 plugin 포맷 입니다 — 위임 경로도 같은 공유 transform tail (ConvertData → SaveFiles) 을 거치기 때문입니다. backend 가 쓰는 raw bundle (JSONL + files + manifest) 은 delegated head 의 중간 산출물이며, SDK 가 이를 읽어 (BundleRead) tail 로 전달합니다. 즉시 in-process 취득으로 되돌리려면 아래 kill-switch 를 사용합니다. 신규 위임 경로는 프로덕션 반영 전 staging 검증을 권장합니다.

게이트, 임계, kill-switch​

setup_steps() 는 위임 결정으로 분기하지 않고, 단일 고정 9-step registry 를 항상 등록합니다 (아래 고정 step registry 와 공유 tail 참고). 전체 게이트는 다음을 모두 만족할 때만 발동합니다 — 위임 enabled (kill-switch off) AND ctx.v2_client wired AND count > threshold (strict >) AND project_id 확보. 결정은 한 번 (_resolve_delegation_decision()) 평가되어 context.delegation_decision 에 seed 되고, 각 head step 의 can_skip 이 이를 참조해 비활성 head 를 완료-pass 합니다. 건수는 v1 단일페이지 count 프로브 (풀스캔 없음) 로 산정하며, 프로브가 count 를 못 구하면 export 는 비위임 (in-process head) 경로로 폴백합니다.

항목envconfig기본값
Size thresholdSYNAPSE_EXPORT_ASYNC_JOB_SIZE_THRESHOLDexport_async_job_size_threshold1000
Kill-switch (in-process 강제)SYNAPSE_EXPORT_DISABLE_ASYNC_DELEGATE=1export_disable_async_delegateoff (위임 on)

이 위임 kill-switch 는 위임 레이어에만 작동합니다 — v1/v2 선택은 바꾸지 않습니다. Export 는 v2-fixed (SYN-7415) 이므로, 위임을 비활성화해도 v2 2-phase in-process 경로는 유지되고 server-side async-job handoff 만 우회합니다:

SYNAPSE_EXPORT_DISABLE_ASYNC_DELEGATEExport path
off (기본)v2 2-phase, count > threshold 시 위임
onv2 2-phase in-process (위임 비활성)
# 위임 레이어만 끄기 (v2 2-phase in-process 유지):
export SYNAPSE_EXPORT_DISABLE_ASYNC_DELEGATE=1

고정 step registry 와 공유 tail​

setup_steps() 는 분기하지 않고 항상 같은 고정 9-step registry (두 acquisition head + 하나의 공유 transform tail) 를 등록합니다. (한 번 프로브된) 위임 결정은 각 head step 의 can_skip 만 뒤집어, 비활성 head 는 완료-pass (skip → 100%) 하고 공유 tail 은 양쪽 경로에서 실행 되어 동일한 plugin 포맷 산출물 을 만듭니다 (FR-8 parity):

영역Steps위임 시비위임 시
in-process headInitialize → FetchResults → PrepareExport (v2 list → bulk-fetch)skip (pass)실행
delegated headDelegateEnqueue (plugin_exports.create) → DelegateStreamProgress (async_jobs.stream_progress SSE) → BundleRead (raw bundle 읽기 + url→bundle_path rewrite)실행skip (pass)
공유 tailConvertData (plugin transform) → SaveFiles → Finalize실행실행

backend 가 쓰는 raw bundle (JSONL + files + manifest) 은 delegated head 의 중간 산출물입니다: BundleRead 가 {prefix}/{tasks|assignments|ground_truths}.jsonl (각 라인 {id, data, files_manifest:{spec:{file_name_original, url, bundle_path}}}) 을 읽어, map_files_to_bundle_local 로 url → {prefix}/{bundle_path} rewrite (traversal-guard, 출력 파일명은 file_name_original 유지) 한 뒤 공유 tail 로 전달합니다. 따라서 위임 export 도 raw bundle 이 아니라 plugin 포맷을 산출합니다.

delegated head 는 세 개의 BackendClientV2 resource 를 사용합니다 — v2_client.plugin_exports.create(...), v2_client.async_jobs.stream_progress(job_id) (SSE), v2_client.async_jobs.retrieve(job_id). backend 는 progress (processed/total) 만 흘리고, event/metric/user-facing 로그는 SDK 가 전담 합니다 (set_progress step server_export, set_metrics, export_info/export_completed/export_failed, EXPORT_DELEGATED_TO_SERVER/EXPORT_DELEGATED_COMPLETED). 전체 계약은 Export Actions — 대량 export 의 서버사이드 위임 과 Steps Workflow — fixed step registry 를 참고하세요.

Nested accessor​

Events endpoint 는 flat resource 와 nested accessor 둘 다 제공합니다. 둘 모두 동일한 backend filter contract 를 사용합니다:

# Flat
client.v2_client.ground_truth_events.list(ground_truth_dataset_version=99)

# Nested (RPC-faithful URL)
client.v2_client.ground_truth_datasets.versions(7).events(99).list()

호출 context 가 이미 owning dataset / version 을 보유한 경우 nested 형식을, cross-dataset analytics 인 경우 flat 형식을 사용하세요.

caller 라우팅 — v1/v2 는 선택이 아니라 고정​

SYN-7415 는 선택 스위치를 제거했습니다. 각 마이그된 callsite 는 하나의 client 로 고정 되어 있으며, callsite 에 use_v2 호출도 _V2_CATEGORY_POLICY 테이블도 런타임 분기도 없습니다:

Caller라우팅이유
export/handlers.pyv2-fixedExport 는 항상 ctx.v2_client 의 v2 2-phase 경로를 수행합니다. v2_client 미배선 시 명시적 ClientError 발생 (v1 fallback 없음).
to_task/steps/fetch_tasks.pyv1-fixed항상 legacy client.list_tasks 경로.
dataset/action.py _download_splitv1-fixed항상 legacy list_ground_truth_events 경로.

env kill-switch 로 export 를 v1 로 롤백하는 방법은 없습니다 — SYNAPSE_FORCE_V1_EXPORT 는 더 이상 존재하지 않습니다. export 는 v2-fixed 이므로, unconfigured 실행 (v2_client 미배선) 은 v1 로 조용히 폴백하는 대신 EXPORT_FAILED_V2_NOT_WIRED 로 명시적으로 실패합니다. v1 동작이 필요하면 SYN-7415 이전 플러그인 SDK 버전을 고정하세요 (런타임 전환이 아님).

이제 export 와 관련해 남은 env switch 는 SYNAPSE_EXPORT_DISABLE_ASYNC_DELEGATE 하나뿐입니다 (아래 게이트, 임계, kill-switch 참고). 이것은 backend 위임 레이어만 비활성화하며, v1/v2 선택에는 영향이 없습니다.

Error envelope​

v2 backend 는 모든 실패를 {"error": {"code": "...", "detail": "..."}} envelope 으로 감쌉니다. SDK 의 classify_export_error 는 v1 regex fallthrough 보다 먼저 이 envelope 을 조회하므로, 알려진 v2 code 는 전용 ExportLogMessageCode (및 retry policy) 로 매핑됩니다:

error.codeExportLogMessageCode
invalid_idsEXPORT_FAILED_BAD_REQUEST
partial_failureEXPORT_FAILED_BATCH_PARTIAL
rate_limitedEXPORT_FAILED_RATE_LIMITED

미지의 v2 code 는 v1 regex 로 fallthrough — v1 회귀 동작이 보존됩니다.

Partial-failure semantics​

Backend V2BulkFetchMixin 은 resolve 불가능한 id 를 silently drop 하고 200 OK 응답에 resolve 된 subset 만 반환합니다 (specs Q2 결정). SDK 는 backend 가 반환한 그대로 반환합니다 — client-side error 도, exception 도 없습니다. 엄격한 resolution 이 필요한 caller 는 len(returned) 와 len(requested) 를 비교하세요:

ids = [1, 2, 999_999]   # 999_999 는 존재하지 않음
rows = client.v2_client.tasks.bulk_fetch(ids)
# len(rows) == 2 — backend 가 missing id 를 silently drop
missing = set(ids) - {row['id'] for row in rows}

files_manifest nullability​

v2 task / assignment payload 의 files_manifest 는 backend PR-A 에 따라 nullable 입니다. File 열거를 기대하는 plugin code 는 None 가드가 필요합니다:

def export_files(row):
manifest = row.get('files_manifest') or {}
for key, info in manifest.items():
...

GroundTruth payload 는 backend PR-C contract 에 따라 항상 non-null dict — ground_truths.bulk_data path 에서는 가드가 불필요합니다.

Plugin 작성자 마이그레이션 cheat sheet​

기존 (v1):

def get_results(self, client, filters):
return client.list_tasks(params=filters, list_all=True)

v2 대응 (v2-fixed export handler 패턴 — SYN-7415):

from synapse_sdk.plugins.actions._v2_fetch import _collect_then_bulk

def get_results(self, client, filters, *, ctx=None):
# Export 는 v2-fixed: 항상 v2 2-phase 경로를 수행합니다. ``ctx.v2_client``
# 미배선 시 명시적 error 발생 (v1 fallback 없음).
v2 = ctx.v2_client # None 이면 raise 하는 것은 handler 의 책임
rows = _collect_then_bulk(
list_method=lambda: v2.tasks.list(list_all=True, **filters),
bulk_method=lambda ids: v2.tasks.bulk_fetch(ids),
)
return rows, len(rows)

v1-fixed caller (예: to_task fetch, dataset download) 는 legacy v1 호출을 그대로 둡니다:

def get_results(self, client, filters):
return client.list_tasks(params=filters, list_all=True)

두 경우 모두 use_v2 호출이 없습니다 — 각 callsite 는 하나의 client 로 고정되어 있습니다.

Observability​

모든 v2 request 의 meta.request_id 는 client.v2_client.last_request_id 로 노출됩니다. capture_request_ids() 로 block scope:

with client.v2_client.capture_request_ids():
rows = client.v2_client.tasks.bulk_fetch([1, 2, 3])
print(client.v2_client.last_request_id)

SDK 의 Sentry integration 이 켜진 경우 request_id 가 Sentry breadcrumb 에 자동 첨부됩니다.

BC policy​

  • SYN-7415 고정 라우팅: export/handlers.py 는 v2-fixed — 항상 ctx.v2_client 에서 v2 2-phase 경로를 수행합니다. v2_client 미배선 시 이제 v1 으로 조용히 폴백하는 대신 명시적 ClientError (EXPORT_FAILED_V2_NOT_WIRED) 가 발생합니다.
  • to_task/steps/fetch_tasks.py 와 dataset/action.py _download_split 는 v1-fixed 입니다.
  • 선택 스위치 (use_v2 / _V2_CATEGORY_POLICY / SYNAPSE_FORCE_V1_EXPORT) 는 제거 되었습니다 — 런타임 v1/v2 롤백이 없습니다.
  • fetch helper 는 _v2_switch 에서 synapse_sdk/plugins/actions/_v2_fetch.py 로 이동했고, _v2_switch 는 backward-compatible re-export shim 으로 남습니다.
  • handler get_results(client, filters, *, ctx=None) 시그니처는 kw-only ctx param 을 유지합니다.
  • _collect_then_bulk 는 기본 id extraction path 에서 dict 형태의 slim row 와 Pydantic list model 을 모두 처리합니다.
  • 새 v2 resource method 는 모두 additive — 어떤 v1 method 도 제거 / 재용도되지 않았습니다.

기존 surface 에 대해 작성된 plugin 은 수정 없이 계속 동작합니다. export caller 를 v2 path 로 마이그레이션하려는 작성자는 위 cheat sheet 를 참고하세요.