~14 min · uploads, progress, idempotency, slow-networks
Level 0밑준비
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
셀 게 하나도 없는 1분
킷 todo #545 전까지 폰은 접시를 멀티파트 본문 하나로 보냈어. 사진 전부랑 폼 필드를 요청 하나에. 업링크가 느리면 그 요청 하나가 기다림 전부였고, 그동안 화면엔 움직이는 게 없었어. 아빠의 보고는 짧았어.
이미지 첨부하고 save 누르면 멍 때림
고친 결과는 킷 벤치의 스로틀링 프록시로 쟀어. 업링크 61.4 KB/s, 큐 256 KB, 편도 150 ms, 상대는 임시 엔진. 고치기 전엔 사진 세 장 2.54 MB가 44.5초에야 처음 화면을 바꿨어. 시트가 닫히는 순간이었고, 진행 줄은 아예 없었지. 네 장 3.53 MB는 똑같이 62.3초. 고친 뒤엔 첫 줄이 2.0초와 2.4초에 떴어. 전체 시간은 44초, 62초로 거의 그대로야. 시간과 바이트는 링크 몫이니까. 바뀐 건 매초 어느 사진이 가는지, 몇 MB 중 몇 MB인지, 속도와 초가 보인다는 거야. 웹은 15분 뒤에 따라왔고, 첫 줄 0.4초, 세 장 끝까지 46.4초가 나왔어.
스테이징 문
엔진은 킷의 kit_staged_uploads를 POST /api/staged-uploads에 달아. 요청 하나엔 client_key와 파일 하나가 실려. 나쁜 연결에서도 안전하게 해 주는 규칙이 넷이야.
키 기준으로 멱등. 스테이징 id는 키의 SHA-256 앞 32자리 16진수야. 그래서 같은 키는 늘 같은 id를 가리켜. 느린 링크에선 바이트는 도착했는데 답이 사라질 수 있어. 다시 보내면 처음 사본의 기록이 돌아오고, 엔진이 재시작한 뒤라도 두 번 쓰이는 건 없어.
가져가고, 나중에 풀어 줘. 스테이징 파일을 읽어도 지워지는 건 없어. 앱은 자기 기록이 커밋된 뒤에야 풀어 줘. 그래서 둘 사이에 죽어도 다시 시도하면 돌아가.
쓸어 내. 일주일 동안 아무도 안 가져간 건 다음 업로드 때 지워져.
읽는 동안 상한을 지켜. 이미지 타입만 받고, 크기 상한(Sizzle은 60 MB)은 본문을 읽는 도중에 조각마다 검사해. 너무 큰 파트는 메모리에 다 올라오기 전에 거절돼.
재시도를 공짜로 만드는 키
두 클라이언트 다 접시 요청 id로 키를 만들어. 모양은 <request id>:files-<from>-<to>. 웹은 킷의 kitUploadFiles로 키 하나에 파일 하나씩 보내고, 도착한 id는 ref에 붙들어 둬. 사진 묶음이 바뀌면 그 id를 잊어. 키가 더는 같은 파일을 가리키지 않으니까. 근데 잊는 것만으론 모자라고, 이건 지금 살아 있는 버그야. 요청 id는 Save가 성공하거나 카드를 초기화할 때까지 그대로야. 그래서 Save가 실패한 뒤 사진 묶음을 바꾸면 같은 위치 키로 다시 묻게 되고, 스테이징 문은 같은 키마다 이미 들고 있는 사본으로 답해. 반쯤 끝난 Save 뒤에 두 번째 사진을 바꾸면, 접시에 예전 두 번째 사진이 붙어 돌아올 수 있어. 고치는 법은 묶음이 바뀔 때마다 요청 id를 새로 찍는 거야. 폰은 이 버그가 없어. 접시의 사진은 outbox 기록이 커밋되는 순간 고정되거든.
폰은 킷의 FileUploadRun을 써. 이건 연달아 오는 파일을 1 MB쯤까지 묶어 배치를 짜. 그 정도 크기의 폰 사진은 혼자 가고, 작은 것 여럿은 배치 키 하나를 같이 써. 그래도 EngineClient.stage는 파일마다 요청을 하나씩 보내고, 키 뒤에 파일 위치를 붙여. 크기가 같으면 배치도 늘 같으니까, 이어서 올릴 때도 같은 키로 물어. 배치 하나가 다 도착할 때마다 그때까지의 id를 outbox 기록의 stagedIds에 써 두고, 다음 패스는 다 못 간 첫 배치부터 시작해. 체크포인트는 사진이 아니라 배치 단위야. 사진 셋짜리 배치에서 두 번째가 실패하면, 첫 번째는 도착했는데 기록엔 안 남아. 그래서 재시도 때 다시 가. 손해는 그 바이트를 한 번 더 보내는 것뿐이야. 같은 키로 스테이징 문에 닿으니까, 문은 이미 들고 있는 사본으로 답해.
마지막으로 접시가 그 사진들을 불러
사진이 다 올라가면 접시 자기 요청이 POST /api/encounters로 가. 요청 id, 폼 필드, 그리고 id마다 staged 필드 하나씩. 엔진은 요청 id부터 확인하고, 재전송이면 저장된 접시로 답해. 아니면 스테이징 파일을 가져와서 접시를 저장하고, 스테이징 사본을 풀어 줘.
따로 처리해야 하는 경우가 하나 있었어. 폰에서 일주일 넘게 기다린 접시는 이미 쓸려 나간 스테이징 id를 불러. 그러면 엔진은 업로드가 여기 없다는 400으로 거절해. 400이면 보통 접시를 세워 둬야 하지만, 폰은 이 경우를 알아보고 stagedIds를 비운 뒤 일시 실패로 바꿔. 그래서 다음 패스가 아직 들고 있는 바이트로 사진을 다시 올려.
세는 줄
줄은 킷 거야. 폰은 UploadStatusLine, 웹은 KitUploadStatus. Uploading photo 2 of 3 · 1.1 of 2.9 MB · 52 KB/s · 21s처럼 사진 3장 중 2번째, 2.9 MB 중 1.1 MB, 속도, 초를 보여줘. 파일이 다 올라가면 Saving the plate · 3s(접시 저장 중)로 바뀌고. 웹이 스테이징에 XMLHttpRequest를 쓰는 건 업로드 진행 이벤트가 본문 바이트를 세 주기 때문이야. fetch로는 못 해. 엔진이 파일 하나를 확인해 주면 그때부턴 확인된 속도를 보여줘서, 브라우저 전송 버퍼가 숫자를 부풀리지 못해.
멈춤은 타이머로 재는 게 아니라 진단하는 거야. 바이트가 20초 동안 안 움직이면 줄이 점검에 들어가. 큰 본문이 아직 기기 전송 버퍼를 빠져나가는 중일 수 있으면 더 기다리고. 점검은 기기 자기 링크부터 보고, 엔진이 상태 읽기에 답하는지 본 뒤, 둘 중 어디가 조용한지 말해. 폰에선 Close를 눌러도 접시는 계속 올라가고, Plates의 그 줄도 같은 줄을 보여줘. 아빠의 두 번째 보고도 같은 커밋에서 고쳤어.
x 눌러도 첨부 이미지 삭제 안 되는 버그
이제 사진 띠가 준비된 사진마다 자기 id를 붙이니까, X 버튼은 딱 그 사진만 지워.
15분 살다 간 문서 한 줄
아키텍처 문서에서 폰 업로드를 다룬 문단은 끝에 웹 클라이언트가 아직 멀티파트 본문 하나로 보낸다고 적어. 그 줄은 1021db2에서 쓰였고, 15분 뒤 00c4e83이 웹을 스테이징으로 옮기면서 틀린 말이 됐어. 이 레슨의 웹 코드가 지금 모양이야.
원칙: 업로드 단위는 셀 수 있을 만큼 작게, 되풀이해도 공짜가 되게 키를 붙여. 그러면 진행을 정직하게 보여줄 수 있고, 링크가 끊겨도 날아가는 건 가던 조각 하나뿐이야.
Code
cwkSizzle, clients/apple/SizzleMobile/Sources/Outbox.swift: drain의 보내기, 스테이징 뒤의 접시, 쓸려 나간 사진 처리까지 (원문 그대로, 118-134행)·swift
let detail: EncounterDetail
do {
let ids = try await run.run(stored: already, send: { batch, key in
try await Self.classified { try await self.stage(batch, key: key) }
}, landed: { ids in try await outbox.setStaged(entry.id, ids) })
await run.finishing()
do {
detail = try await Self.classified { try await capture(entry.payload, requestId: entry.id, staged: ids) }
} catch OutboxFailure.permanent(let reason) where reason.contains("is not here") {
// Staged a week ago and swept: stage them again on the next pass.
try await outbox.setStaged(entry.id, nil)
throw OutboxFailure.transient("The engine no longer had this plate's photos; they go up again.")
}
} catch let failure as OutboxFailure {
await run.fail(failure)
throw failure
}
cwkSizzle, frontend/src/pages/Plates.tsx: 웹의 Save, 요청 하나에 사진 한 장, 이어서 그 사진들을 부르는 접시 (원문 그대로, 106-139행)·typescript
// A photo per request with the line live (cwkSiblingKit todo #545), then the
// plate naming them. Everything is keyed by the plate's request id, so a
// retry resends only what did not land and never makes a second plate.
const save = async () => {
if (!files.length || saving) return;
setSaving(true);
try {
const ids = await kitUploadFiles(files, {
stored: staged.current,
send: (file, index, onProgress) => api.stage(file, `${requestId.current}:files-${index}-${index + 1}`, onProgress),
onStatus: setUpload,
landed: (stored) => {
staged.current = stored;
},
});
setUpload((s) => s && kitUploadPhase(s, "finishing"));
const form = new FormData();
form.append("request_id", requestId.current);
form.append("kind", kind);
form.append("note", note);
form.append("captured_at", new Date().toISOString());
form.append("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone);
ids.forEach((id) => form.append("staged", id));
await api.capture(form);
reset();
setError(null);
onSaved();
} catch (exc) {
// The request id and the staged photos stay: Retry resends only what was lost.
setUpload((s) => s && kitUploadPhase(s, "failed", (exc as Error).message));
} finally {
setSaving(false);
}
};
cwkSizzle, backend/tests/test_capture.py: 두 단계 다 재전송해도 접시는 하나, 스테이징 파일은 풀려 (원문 그대로, 62-83행)·python
def test_photos_staged_one_per_request_become_the_plate(make_client, fake_pippa, tmp_path):
"""The phone since cwkSiblingKit #545: each photo to /api/staged-uploads under
its own key, then the plate names them. A replay of either step is free."""
client = make_client()
ids = []
for index in range(3):
key = f"plate-req-staged-01:files-{index}-{index + 1}"
files = {"file": (f"plate-{index + 1}.jpg", jpeg_bytes(), "image/jpeg")}
first = client.post("/api/staged-uploads", data={"client_key": key}, files=files)
assert first.status_code == 200, first.text
again = client.post("/api/staged-uploads", data={"client_key": key}, files=files)
assert again.json() == first.json()
ids.append(first.json()["id"])
body = client.post("/api/encounters", data={"request_id": "req-staged-01", "kind": "out", "staged": ids})
assert body.status_code == 200, body.text
plate = body.json()
assert len(plate["photos"]) == 3
# Released once the plate committed; a lost reply's retry still answers the plate.
assert not list((tmp_path / "sizzle-db" / "staged").glob("*.bin"))
replay = client.post("/api/encounters", data={"request_id": "req-staged-01", "kind": "out", "staged": ids})
assert replay.status_code == 200
assert replay.json()["encounter"]["id"] == plate["encounter"]["id"]
네 앱에서 파일 여러 개를 요청 하나로 보내는 업로드를 찾아. 요청 하나에 파일 하나씩 클라이언트 키로 저장하는 스테이징 엔드포인트를 더해. 저장 id는 그 키에서 만들고, 같은 키가 다시 오면 처음 사본으로 답하게 해. 클라이언트는 기록의 요청 id와 파일 위치로 만든 키로 파일마다 스테이징하고, 도착한 id를 붙들고 있다가 재시도 땐 마지막 것 다음부터 이어 가게 바꿔. 그러고 나서 스테이징 id를 부르는 기록을 보내. 업링크를 느리게 걸어 두고, 고치기 전과 후에 첫 반응이 언제 화면에 뜨는지 재 봐.
Hint
브라우저 개발자 도구도, macOS의 Network Link Conditioner도 업링크를 느리게 만들 수 있어. 클라이언트 키를 해시해서 id로 쓰면, 서버가 재시작해도 이미 도착한 파일을 잊지 않아. 스테이징 파일은 그걸 가져간 기록이 커밋된 다음에만 지우고, 아무도 안 가져간 건 며칠 단위로 쓸어 내. 파일 묶음이 바뀌면 요청 id도 새로 찍어. 안 그러면 같은 키가 이제 묶음에 없는 파일을 돌려줘.
Progress
Progress is local-only — sign in to sync across devices.