한도 값을 모르는 API의 호출량 제한까지 고친 뒤 마지막으로 남은 것이 백그라운드 워커였다. 이 워커는 슬롯 반납과 철거, 승격처럼 되돌릴 수 없는 작업을 실행한다.

Promise.race는 취소가 아니다

작업에 시간 제한을 두는 코드가 이렇게 생겼다.

/** 정해진 시간을 넘기면 거부한다. 안에서 돌던 작업은 계속 흐르지만 결과는 버린다. */
function withTimeout(promise, ms, label) {
  const limit = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`${label} 이(가) ${ms}ms 를 넘겨 중단했습니다`)), ms);
  });
  return Promise.race([promise, limit]).finally(() => clearTimeout(timer));
}

주석에 동작이 정확히 적혀 있다. 결과는 버리지만 작업은 계속 흐른다. 조회 위주 작업이라면 낭비로 끝나는데 이 워커는 삭제를 한다.

반납 작업에서 실제로 일어나는 순서다.

sequenceDiagram
    participant U as 사용자 화면
    participant W as 워커
    participant T as 반납 작업
    participant P as PaaS

    W->>T: 실행 시작
    T->>P: DB 삭제
    Note over W: 시간 제한 초과
    W->>U: "반납 실패. 다시 시도하세요"
    W->>W: 슬롯을 점유 상태로 되돌림
    T->>P: 환경변수 삭제 (계속 진행)
    T->>P: 볼륨 삭제
    T->>P: force push
    T->>W: 슬롯을 vacant 로 변경
    T->>U: "반납이 끝났습니다"

사용자는 실패 안내를 보고 몇 분 뒤 성공 알림을 받는다. 그 사이에 다시 시도하면 반납 작업 두 개가 동시에 force push를 실행한다. 복구 처리로 "일부가 이미 지워졌을 수 있으니 상태를 확인한 뒤 다시 반납하세요" 안내가 표시된다. 무엇이 지워졌는지는 작업 로그에만 있고 화면에서 그 로그로 가는 경로가 없다.

단계마다 작업이 아직 유효한지 확인한다

AbortSignal을 태스크 안쪽까지 넘겨 fetch를 실제로 중단하는 것이 이상적이지만 호출 경로가 깊어서 변경 범위가 커진다. 실제로 관찰되는 피해는 버려진 작업이 계속 지우는 것과 상태를 덮어쓰는 것이다.

작업 상태를 다시 읽어 내 것이 아직 running인지 확인하는 함수를 만들었다.

function assertStillMine(job) {
  const now = q.getJob(job.id);
  if (!now || now.state !== 'running') {
    const err = new Error(`작업 #${job.id} 이(가) 더 이상 진행 중이 아닙니다(${now ? now.state : '없음'}).`);
    err.code = 'job_superseded';
    throw err;
  }
}

워커는 시간 제한을 넘기면 즉시 작업을 failed로 기록한다. 그래서 버려진 작업이 다음 단계에서 이 확인에 걸린다. 파괴적 단계 앞과 마지막 상태 쓰기 앞에 넣었다.

assertStillMine(job);          // 1) DB 삭제 앞
assertStillMine(job);          // 2) 환경변수와 볼륨 삭제 앞
assertStillMine(job);          // 4) force push 앞. 되돌릴 수 없다
// ...
assertStillMine(job);          // 마지막. 여기서 걸리면 vacant 로 바꾸지 않는다
q.updateSlot(slot, { occupancy: 'vacant', state_reason: '반납 완료' });
await notify.send('반납이 끝났습니다');

테스트로 확인한 것은 버려진 작업이 던져서 멈추는지, 슬롯을 vacant로 바꾸지 않는지, "반납 완료"로 덮어쓰지 않는지다. 정상 작업이 이 확인에 걸리지 않는 것도 함께 본다.

테스트를 처음 실행했을 때 정상 경로 세 건이 job_superseded로 실패했다. 테스트가 작업을 만들고 running으로 표시하지 않았기 때문이다. 그 표시는 워커가 한다. 확인 함수가 의도대로 동작한 결과였고 테스트를 고쳤다.

철거가 추적 단서까지 지웠다

철거는 리포까지 삭제한다. 이 작업의 모든 삭제 실패가 로그에만 남았다.

await coolify.deleteDatabase(db.uuid).catch(() => {});
log(`DB 삭제: ${db.name}`);                    // 실패해도 남는다
await coolify.deleteApplication(app.uuid).catch((err) => log(`앱 삭제 실패: ${err.message}`));
await bb.deleteRepo(slot).catch((err) => log(`리포 삭제 실패: ${err.message}`));

q.updateSlot(slot, {
  lifecycle: 'retired',
  app_uuid: null,        // 남은 자원을 찾을 단서를 지운다
  project_uuid: null,
  state_reason: '철거됨',
});

작업은 완료로 끝나고 슬롯은 retired가 되고 알림으로 철거됐다는 문구가 나간다. 그리고 app_uuidproject_uuidnull로 만들어 남은 자원을 다시 찾을 방법을 없앤다. 정합성 검사는 retired 슬롯을 보지 않으므로 고아 자원이 영구히 드러나지 않는다.

삭제 실패를 모아 던진다. uuid는 전부 지운 것이 확인됐을 때만 지우도록 바꿨다. 실패했을 때는 남은 자원 목록을 슬롯 상태 사유에 적는다.

if (failures.length) {
  q.updateSlot(slot, { state_reason: `철거 중 남은 자원이 있습니다: ${failures.join(', ')}` });
  throw new Error(`지우지 못한 자원이 있어 철거를 완료하지 않습니다(${failures.join(', ')}).`);
}

승격의 중복 확인이 조회 실패로 우회됐다

승격은 슬롯 코드를 새 리포로 옮기는 작업이다. 이름이 이미 쓰이고 있으면 중단해야 한다.

export async function getRepo(name) {
  const res = await request('GET', `${projectPath()}/repos/${repoSlug(name)}`);
  return res.ok ? res.body : null;      // 401, 403, 500 도 null
}
const existing = await bb.getRepo(newRepo);
if (existing) throw new Error(`리포 ${newRepo} 이 이미 있습니다.`);
await bb.createRepo(newRepo);           // 반환값을 확인하지 않는다
log(`새 리포 생성: ${newRepo}`);

조회가 실패하면 null이 되고 그건 없다는 뜻으로 읽히므로 중복 확인을 그대로 지나간다. createRepo는 409를 받으면 { created: false, existed: true }를 돌려주는데 아무도 읽지 않으므로 로그에는 새 리포를 생성했다고 남는다.

이관 함수가 --force를 쓰지 않으므로 내용이 있는 리포에는 push가 거부된다. 그런데 과거 실패로 만들어진 빈 리포에는 그대로 들어간다. 사용자는 자기 코드가 엉뚱한 리포에 들어간 것을 모른다.

없는 것과 확인 실패를 구분하도록 반환 형태를 바꿨다. 404만 없음이다.

export async function getRepo(name) {
  const res = await request('GET', `${projectPath()}/repos/${repoSlug(name)}`);
  if (res.ok) return { found: true, repo: res.body, error: null };
  if (res.status === 404) return { found: false, repo: null, error: null };
  return { found: false, repo: null, error: `http ${res.status}` };
}

확인 실패면 승격을 중단한다. createRepoexisted가 참이면 실패로 끝낸다.

작업 클레임에 조건이 없었다

워커가 다음 작업을 고르고 실행 중으로 표시하는 코드가 이렇게 나뉘어 있었다.

const job = q.nextRunnableJob();          // SELECT ... WHERE state = 'queued' LIMIT 1
q.markJob(job.id, 'running');             // UPDATE ... WHERE id = ?

UPDATE의 조건이 id뿐이다. 지금은 워커가 한 프로세스뿐이고 고르는 것과 표시하는 것 사이에 await가 없어서 경합이 없다. 그런데 컨테이너를 두 대로 늘리는 순간 같은 반납 작업을 두 워커가 집어 force push가 중복 실행된다. 되돌릴 수 없는 작업이라 스케일아웃 전에 막아 두는 편이 낫다.

조건부 UPDATE로 가져오고 갱신된 행이 하나일 때만 실행한다.

export function claimJob(id) {
  const info = getDb()
    .prepare("UPDATE jobs SET state = 'running', started_at = ? WHERE id = ? AND state = 'queued'")
    .run(now(), id);
  return Number(info.changes) === 1;
}
if (!q.claimJob(job.id)) {
  console.warn(`[worker] 작업 #${job.id} 은 이미 다른 워커가 가져갔습니다`);
  return true;
}

다섯 번 동시에 집어도 한 번만 성공한다. 실패나 완료로 끝난 작업은 다시 가져갈 수 없는 것을 테스트로 확인했다.

반납 대기 화면이 실패를 성공으로 보고했다

반납은 유예 시간을 두고 실행하므로 대기 화면에서 진행 상태를 폴링한다. 서버가 상태를 찾는 방식이 최근 목록을 훑는 것이었다.

const last = q.listJobs(40).find((j) => j.slot === name && j.type === 'reset');
res.json({ state: last ? last.state : 'none' });

다른 슬롯의 작업이 40건 넘게 쌓이면 찾지 못하고 none이 된다. 클라이언트는 done, failed, none을 한 묶음으로 처리했다.

if (data.state === 'queued') return false;
// done, failed, none 은 모두 끝난 상태다.
toast(data.state === 'failed' ? '반납 중 문제가 생겼습니다' : '반납이 끝났습니다');
setTimeout(() => { location.href = '/slots'; }, 1200);

none은 상태를 찾지 못했다는 뜻인데 그것을 성공으로 취급해 "반납이 끝났습니다"를 표시하고 목록으로 이동했다. 슬롯과 작업 종류로 직접 조회하는 쿼리를 추가했다. none은 "진행 상태를 확인할 수 없습니다"로 두고 이동하지 않게 했다.

폴링이 조용히 영구 정지했다

클라이언트 폴링에 시간 제한이 없었다.

const res = await fetch(url, { headers: { Accept: 'application/json' } });
// ...
timer = setTimeout(tick, wait);    // await 이 settle 되어야 실행된다

응답이 오지 않으면 이 await가 끝나지 않는다. 그러면 다음 주기 예약 자체가 일어나지 않는다. 화면은 그 순간 값에서 굳고 오류 표시도 없다. 사용자는 아직 진행 중으로 읽는다.

관문을 넣은 뒤 서버 쪽 대기가 길어질 수 있어서 이 경로가 더 위험해졌다. 예산 대기 20초에 fetch 30초를 재시도 3회면 프래그먼트(서버가 다시 렌더해 돌려주는 화면 일부) 요청 하나가 150초까지 걸린다. AbortSignal.timeout()을 붙였다.

오류 배너도 한 번만 표시되고 있었다.

if (fails === FAILS_BEFORE_NOTICE) { banner(...); }

fails는 4, 5, 6으로 계속 증가하므로 정확히 3일 때만 표시된다. 한 번 지워지면 다시 표시되지 않는다. 게다가 배너를 여러 폴러가 공유한다. 상세 화면에는 폴러가 네 개 돌고 그중 사이드바 건수 폴러는 DB만 조회하므로 거의 항상 성공한다. 그것이 실패 중인 폴러의 경고를 계속 지웠다. 조건을 >=로 바꿨다. 실패 중인 폴러 집합을 세어 마지막 하나가 회복될 때만 지우게 했다.

카운트다운 인터벌이 누적됐다

기한 표시를 요소마다 setInterval로 갱신하고 있었다.

const id = setInterval(() => { if (render()) clearInterval(id); }, period);

프래그먼트 갱신이 영역을 innerHTML로 교체하면 옛 노드는 사라지는데 그 인터벌은 정리되지 않고 계속 실행된다. 점유 중 슬롯 8개 화면을 10초마다 갱신하면 1분에 48개, 한 시간에 약 2,880개의 1초 인터벌이 남는다. 하루 종일 열어 두는 화면이라 시간이 지나면 탭이 느려진다.

전역 티커 하나가 매초 [data-countdown] 전체를 그리도록 바꿨다. 교체된 노드는 자연히 빠지고 새 노드는 자연히 들어온다. 등록 여부를 표시하던 속성도 필요 없어졌다.

결과

라이브 검증 106항목과 E2E 19항목, 단위 테스트 199항목이 통과한다. 엄격해진 반납 코드가 배포된 뒤 실제 반납을 한 번 실행해 정상 완료되는 것과, 목록 조회 실패 시 던지는 변경이 정상 경로를 막지 않는 것을 확인했다.

남은 문제

AbortSignal을 태스크 안쪽까지 넘기지 않았다. 단계 사이의 확인으로 삭제가 계속되는 것은 막았다. 다만 한 단계 안에서 진행 중인 HTTP 호출은 그대로 완료된다. 단계 하나가 긴 작업(force push)에서는 버려진 뒤에도 그 단계가 끝까지 실행된다.

작업 로그로 가는 경로가 화면에 없다. 복구 안내에는 "일부가 이미 지워졌을 수 있으니 상태를 확인하세요"라고 적혀 있는데 무엇이 지워졌는지는 로그에만 있다. 복구 시 상태 사유에 작업 링크와 완료된 단계 목록을 담는 것이 다음 작업이다.