Krong Dev.
오픈소스Fedify테스트

Fedify #879 : KV 락 재시도를 스텁이 아닌 상태로 검증하기

Fedify의 @fedify/cfworkers가 Workers KV의 최종적 일관성 때문에 best-effort로만 순서를 보장하는 이유를 정리하고, 이슈 #879에서 vi.spyOn() 스텁 대신 실제 상태를 가진 가짜 KV로 락 재시도 테스트를 작성한 과정을 담습니다.

Fedify #879 : KV 락 재시도를 스텁이 아닌 상태로 검증하기

지난 listen() 테스트 글에서는 WorkersMessageQueue.listen()이 왜 항상 TypeError를 던지는지 검증했고, 글 마지막에 다음 이슈로 #879를 예고했습니다.

github [#142] Created with Sketch.
Test locked queue messages in @fedify/cfworkersClosed
#879dahlia지난달에 종료

이번 글에서는 그 약속대로, 같은 패키지의 processMessage()가 락(lock)이 걸린 메시지를 어떻게 다루는지 검증하는 과정을 정리합니다.


#879 이슈 이해하기

이슈 본문은 다음과 같습니다.

GitHub 이슈 #879 화면
이번에 맡은 이슈 #879 : https://github.com/fedify-dev/fedify/issues/879

한 줄로 요약하면 ’KV 락이 이미 걸려 있으면 메시지를 재시도로 돌리는 동작을 테스트로 증명하라’는 것인데, 여기서 의문이 들었습니다.

“왜 Workers KV는 완벽하게 순서를 보장하지 못할까요?”

이 질문에 답하려면 먼저 Workers KV가 어떤 저장소인지 알아야 합니다.

Workers KV는 왜 “최선형” 순서 보장만 가능할까?

Cloudflare Workers KV는 공식 문서에서 다음과 같이 설명됩니다.

Cloudflare Workers KV

Workers KV is a global, low-latency, key-value data store for building dynamic and performant APIs and websites.

developers.cloudflare.com

a global, low-latency, key-value data store for building dynamic and performant APIs and websites

전 세계에 분산되어 있는 저지연(low-latency) 키-값 저장소라는 뜻입니다. 즉, 이 기능 덕분에 전 세계에 있는 사용자들에게 빠르게 일관된 데이터를 제공할 수 있습니다.

그런데 전 세계에 흩어져 있는 저장소가 어떻게 어디서나 빠를 수 있을까요?
공식 문서에서는 그 비결과 대가를 이렇게 설명합니다.

How KV works

Workers KV stores data centrally and caches it globally, optimizing for high-read, low-latency workloads.

developers.cloudflare.com

KV achieves high performance by being eventually-consistent. At the Cloudflare global network location at which changes are made, these changes are usually immediately visible. In other global network locations changes may take up to 60 seconds or more to be visible as their cached versions of the data time-out.

높은 성능의 비결이 최종적 일관성(eventual consistency)이라는 것입니다.
쉽게 얘기하면, 값을 쓴 지역과 그 근처의 지역에서는 보통 바로 보이지만, 다른 지역에서는 캐시된 이전 값이 만료되어 업데이트되기까지 최대 60초 이상 소요되기 때문에 같은 값을 보지 못하는 타이밍이 생긴다는 겁니다.

즉, Workers KV는 ’모든 지역이 항상 같은 값을 본다’는 보장을 양보하는 대신 속도를 가져간 저장소입니다.

Fedify 공식 문서도 이 특성을 그대로 반영해서 순서 보장의 한계를 명시합니다.

Message queue | Fedify

Fedify docs

fedify.dev
Fedify 공식 문서의 Ordering guarantees 설명
출처: Fedify Message queue - Ordering guarantees

실제 @fedify/cfworkers 소스 코드에도 같은 경고가 주석으로 남아 있습니다.

typescript
// packages/cfworkers/src/mod.ts

export interface WorkersMessageQueueOptions {
  /**
   * The KV namespace to use for ordering key locks.  If not provided, ordering
   * keys will not be supported.
   *
   * Note: Cloudflare Workers KV has eventual consistency, so ordering key
   * guarantees are best-effort.  For strict ordering requirements, consider
   * using Durable Objects.
   */
  readonly orderingKv?: WorkersKvNamespaceLike<string>;
}

더 들어가보면, processMessage() 구현부에는 더 구체적인 이유가 적혀 있습니다.

typescript
// Try to acquire lock
// Note: Workers KV doesn't support atomic CAS, so there's a race condition
// window.  This is best-effort ordering.
await this.#orderingKv.put(lockKey, Date.now().toString(), {
  expirationTtl: this.#orderingLockTtl,
});

CAS(Compare-And-Swap)는 ’락이 없을 때만 락을 건다’를 한 번에 원자적으로 처리하는 방법입니다.


Workers KV에는 이 연산이 없기 때문에, get()으로 락이 없음을 확인한 직후 다른 요청이 끼어들어 똑같이 락이 없다고 판단하고 동시에 put()을 호출할 수 있는 경쟁 상태(race condition) 창 이 생깁니다.

이 한계는 Cloudflare도 같은 문서에서 분명히 밝히고 있습니다.

KV is not ideal for applications where you need support for atomic operations or where values must be read and written in a single transaction. If you need stronger consistency guarantees, consider using Durable Objects.

앞서 본 Fedify 소스 주석의 마지막 줄이 “For strict ordering requirements, consider using Durable Objects”였던 것도, 결국 동일한 안내를 담고 있습니다.

정리하면 Workers KV는 1. 쓰기가 다른 지역에 반영되기까지 지연이 있고, 2. 읽기, 쓰기를 한 번에 묶는 CAS 연산이 없습니다.
그래서 @fedify/cfworkers의 락은 “반드시 보장”이 아니라 “최대한 지켜본다”는 뜻의 최선형으로 설계되어 있습니다.

이슈 내용 하나씩 뜯어보기

이제 이슈 문장을 하나씩 뜯어보겠습니다.

“@fedify/cfworkers uses KV locks to provide best-effort ordering for Workers Queues.”

→ @fedify/cfworkers는 Workers Queues의 순서를 보장하기 위해 KV 락을 쓰는데, 앞서 본 대로 그 보장 수준은 최선형입니다.

“If a lock already exists, the message must be retried later instead of being processed immediately.”

→ 락이 이미 존재한다면, 메시지는 즉시 처리되지 않고 나중에 재시도되어야 합니다.

“Add a test in packages/cfworkers/src/mod.test.ts that verifies WorkersMessageQueue.processMessage() returns shouldProcess: false when the ordering key lock already exists.”

→ 순서 키(ordering key) 락이 이미 존재할 때 WorkersMessageQueue.processMessage()가 shouldProcess: false를 반환하는지 검증하는 테스트를 packages/cfworkers/src/mod.test.ts에 추가하라는 지시입니다.


shouldProcess: false는 누가 받아보나

그런데 shouldProcess: false를 돌려주는 것만으로 ’나중에 재시도’가 되지는 않습니다.
실제로 재시도를 실행하는 쪽은 Worker의 queue() 핸들러이고, Fedify는 그 사용법을 processMessage()의 JSDoc 예시로 안내하고 있습니다.

typescript
// packages/cfworkers/src/mod.ts (processMessage()의 JSDoc 예시)

export default {
  async queue(batch, env, ctx) {
    const queue = new WorkersMessageQueue(env.QUEUE, {
      orderingKv: env.ORDERING_KV,
    });
    for (const msg of batch.messages) {
      const result = await queue.processMessage(msg.body);
      if (!result.shouldProcess) {
        msg.retry(); // Re-enqueue to wait for lock
        continue;
      }
      try {
        await federation.processQueuedTask(ctx, result.message);
        msg.ack();
      } catch (e) {
        msg.retry();
      } finally {
        await result.release?.();
      }
    }
  },
};

shouldProcess가 false면 핸들러는 msg.retry()로 메시지를 큐에 되돌리고, 그 사이 먼저 처리 중인 메시지가 release()로 락을 풀면 다음 차례가 열립니다.
즉 이번에 검증할 shouldProcess: false는 ’재시도해라’라는 신호(signal)이고, 이 신호가 끊기면 같은 순서 키의 메시지가 순서를 무시하고 동시에 처리됩니다.


테스트 작성 과정

검증 목표

이슈가 요구하는 것은 명확합니다.

  1. ordering key로 락을 건 상태를 만든다
  2. 같은 ordering key로 다시 processMessage()를 호출했을 때 shouldProcess: false가 반환되는지 확인한다

processMessage()의 실제 락 판정 로직은 다음과 같습니다.

typescript
// packages/cfworkers/src/mod.ts

async processMessage(rawMessage: any): Promise<ProcessMessageResult> {
  const wrapped = rawMessage as WrappedMessage;
  const orderingKey = wrapped.__fedify_ordering_key__;
  const message = "__fedify_payload__" in wrapped
    ? wrapped.__fedify_payload__
    : rawMessage;

  if (orderingKey == null || this.#orderingKv == null) {
    return { shouldProcess: true, message };
  }

  const lockKey = this.#getOrderingLockKey(orderingKey);

  const existing = await this.#orderingKv.get(lockKey);
  if (existing != null) {
    return { shouldProcess: false };
  }

  await this.#orderingKv.put(lockKey, Date.now().toString(), {
    expirationTtl: this.#orderingLockTtl,
  });

  const release = async (): Promise<void> => {
    await this.#orderingKv!.delete(lockKey);
  };

  return { shouldProcess: true, message, release };
}

제가 검증해야 할 스코프는 existing != null일 때 { shouldProcess: false }만 반환하고, 반드시 message와 release는 주지 않는다는 부분입니다.

여기서 한 가지 눈여겨볼 점이 있습니다.

락을 거는 경로(put() 후 shouldProcess: true)와 락이 이미 있는 경로(shouldProcess: false)가 같은 함수 안에 붙어 있다는 것입니다. 이 둘은 코드상 몇 줄 차이라 테스트를 쓸 때 헷갈리기 쉬운데, 실제로 제가 처음에 블로킹된 지점이기도 합니다. 이 얘기는 후기에서 다시 하겠습니다.


이미 같은 이름의 테스트가 있었다

새 테스트를 짜기 전에 비슷한 테스트가 있는지부터 찾아봤는데, 뜻밖에도 제목까지 똑같은 테스트가 이미 있었습니다.

typescript
// packages/cfworkers/test/mq.test.ts

it("returns shouldProcess=false when lock exists", async () => {
  const getSpy = vi.spyOn(env.KV1, "get").mockResolvedValue("locked");

  const queue = new WorkersMessageQueue(env.Q1, { orderingKv: env.KV1 });
  const result = await queue.processMessage({
    __fedify_ordering_key__: "key1",
    __fedify_payload__: { data: "test" },
  });

  expect(result.shouldProcess).toBe(false);
  expect(result.message).toBeUndefined();
  expect(result.release).toBeUndefined();

  getSpy.mockRestore();
});

그럼 이 이슈는 왜 아직 열려 있었을까요?

vi.spyOn(env.KV1, "get").mockResolvedValue("locked") 한 줄에 있습니다. 이 줄은 KV의 get() 메서드를 가로채서, 무엇을 묻든 무조건 "locked"라고 답하게 만듭니다.

한편 이슈가 지목한 src/mod.test.ts에는 전혀 다른 방식의 테스트가 있었습니다.

typescript
// packages/cfworkers/src/mod.test.ts

it("processMessage() - release() frees the ordering lock", async () => {
  const orderingKv = new MockKvNamespace();
  const queue = new WorkersMessageQueue(mockQueue, { orderingKv });

  const first = await queue.processMessage({
    __fedify_ordering_key__: "actor-1",
    __fedify_payload__: { id: "first" },
  });

  expect(first.shouldProcess).toBe(true);
  expect(orderingKv.entries.has("__fedify_ordering_actor-1")).toBe(true);

  await first.release?.();

  expect(orderingKv.entries.has("__fedify_ordering_actor-1")).toBe(false);

  const second = await queue.processMessage({
    __fedify_ordering_key__: "actor-1",
    __fedify_payload__: { id: "second" },
  });

  expect(second.shouldProcess).toBe(true);
});

MockKvNamespace는 get/put/delete를 전부 직접 구현해 둔 가짜 저장소입니다.
processMessage()를 두 번 호출하면서, 첫 호출이 만든 락을 release()로 지우면 두 번째 호출이 다시 통과하는지를 저장소의 실제 상태로 확인합니다.

두 파일의 차이를 정리하면 이렇습니다.

구분 test/mq.test.ts src/mod.test.ts
쓰는 KV wrangler에 선언된 바인딩 env.KV1 직접 만든 MockKvNamespace
락 상태 vi.spyOn()으로 답을 고정 put()/get()이 실제로 오감
확인하는 것 반환값이 정해진 답을 따르는가 저장소에 남은 상태가 맞는가

두 파일은 같은 Workers 테스트 환경에서 돕니다.


그래서 재미있는 점은, 진짜 KV 바인딩을 쓸 수 있는 쪽(test/mq.test.ts)이 오히려 스파이로 그 KV를 무력화하고 있고, 가짜 저장소밖에 없는 쪽(src/mod.test.ts)이 읽기·쓰기를 실제로 왕복시키고 있다는 것입니다.

제가 작성할 테스트는 후자의 구조를 그대로 가져가되, release()를 호출하지 않고 바로 두 번째 메시지를 보내면 되겠다는 그림이 나왔습니다.


최종 테스트 코드

typescript
// packages/cfworkers/src/mod.test.ts

it("processMessage() - returns shouldProcess=false when lock exists", async () => {
  const orderingKv = new MockKvNamespace();
  const queue = new WorkersMessageQueue(mockQueue, { orderingKv });

  const first = await queue.processMessage({
    __fedify_ordering_key__: "key1",
    __fedify_payload__: { id: "first" },
  });

  expect(first.shouldProcess).toBe(true);
  expect(first.message).toEqual({ id: "first" });

  const second = await queue.processMessage({
    __fedify_ordering_key__: "key1",
    __fedify_payload__: { id: "second" },
  });

  expect(second.shouldProcess).toBe(false);
  expect(second.message).toBeUndefined();
  expect(second.release).toBeUndefined();
});

동작 설명

  • 'first' 메시지가 processMessage()를 통과하면서 key1에 대한 락을 실제로 생성합니다 (shouldProcess: true).
  • 락을 해제하지 않은 채 같은 key1로 'second' 메시지를 보내면, processMessage()가 자기 자신이 직전에 만든 락을 다시 읽어서 shouldProcess: false를 돌려줍니다.
  • 이때 message와 release는 undefined인데, 처리하지 않을 메시지에 release 콜백을 쥐어주면 호출하는 쪽이 실수로 아직 유효한 락을 지울 수 있기 때문입니다.

앞의 queue 핸들러 예시로 바꿔 말하면, 이 테스트는 ’첫 메시지가 처리되는 동안 같은 액터의 두 번째 메시지가 도착하면 핸들러가 msg.retry()로 보내게 되는가’를 확인하는 셈입니다.

vi.spyOn()과 vi.fn(), 그리고 스텁이 놓치는 것

앞서 본 두 테스트의 차이를 조금 더 짚고 넘어가고 싶습니다.
Vitest는 “가짜로 답하게 만드는” 도구로 vi.fn()과 vi.spyOn() 두 가지를 제공합니다.

Vitest

Next generation testing framework powered by Vite

vitest.dev

[vi.fn] Creates a spy on a function, but can also be initiated without one. Every time a function is invoked, it stores its call arguments, returns, and instances.

[vi.spyOn] Creates a spy on a method or getter/setter of an object similar to vi.fn(). It returns a mock function.

정리하면 vi.fn()은 독립된 가짜 함수를 새로 만드는 것이고, vi.spyOn()은 이미 존재하는 객체의 메서드를 가로채서 호출 여부를 기록하거나 반환값을 바꿔치기하는 것입니다.

test/mq.test.ts의 테스트는 후자의 경우로, KV의 get() 자체를 가로채 무조건 "locked"를 반환하게 만들었고, ’get()이 락 값을 반환하면 processMessage()가 shouldProcess: false를 돌려준다’는 분기는 확실히 검증합니다.

하지만 PR 본문에서 밝혔듯, 이 방식에는 빈틈이 있습니다.

test/mq.test.ts already covers that branch, but it stubs get() to return a lock unconditionally, so the lock key itself is never exercised.

get()을 통째로 가짜 답변으로 바꿔버리면, processMessage()가 자기 자신이 쓴 키를 제대로 다시 읽어오는지는 영영 테스트되지 않습니다.
가령 put()이 __fedify_ordering_key1에 쓰는데 get()은 다른 키를 읽는 버그가 생겨도, 스텁 테스트는 그대로 통과합니다. 답을 이미 정해놨기 때문입니다.

그래서 제가 작성한 테스트는 get/put/delete가 전부 실제로 동작하는 가짜 저장소를 쓰고, processMessage()를 두 번 진짜로 호출하는 방식을 택했습니다.
이렇게 하면 PR 본문의 다음 문장처럼 ’쓴 키와 읽은 키가 같다’는 것까지 자연스럽게 따라옵니다.

That also proves the key processMessage() writes is the one it later reads back.

덧붙이면, “스텁으로 답을 정해두는 것”과 “진짜 상태를 가진 가짜 객체로 동작을 재현하는 것”의 구분은 Vitest 문서가 직접 설명하는 개념은 아닙니다.
테스트 더블(test double) 일반론에 가까운 이야기라, 공식 문서 인용이 아니라 이번 작업에서 얻은 관점으로 적어 둡니다.

실험 : 테스트를 믿기 전에 일부러 깨뜨려 보기

새 테스트가 초록불이 떴다고 해서 그게 로직을 검증한 증거는 아닙니다.
지난 글에서 TypeError를 Error로 바꿔 테스트가 실제로 반응하는지 확인했던 것처럼, 이번에도 구현을 일부러 깨뜨려 봤습니다.

우선 정상 상태에서 이 테스트만 돌려봅니다.

bash
cd packages/cfworkers
mise exec -- pnpm exec vitest run src/mod.test.ts -t "lock exists"

vitest 출력 캡처

그다음 processMessage()의 락 체크 분기를 아래처럼 바꿉니다. 락이 있어도 없는 척하게 만드는 것입니다.

typescript
// packages/cfworkers/src/mod.ts — 일부러 깨뜨린 상태

const existing = await this.#orderingKv.get(lockKey);
if (false) {
  return { shouldProcess: false };
}

결과 : 두 번째 메시지가 락을 무시하고 통과해버리므로, expect(second.shouldProcess).toBe(false)가 expected true to be false로 실패합니다.

vitest 출력 캡처

테스트가 깨진 구현을 제대로 잡아냈으니, 이제 원래대로 되돌리고 패키지 전체를 확인합니다.

bash
mise run check-each cfworkers
mise exec -- pnpm exec vitest run

vitest 출력 캡처

여기서 주의할 점은, 이 실험이 증명한 것은 ’내가 추가한 테스트가 공허하게 통과하지 않는다’는 사실까지라는 것입니다.
if (false)로 바꾸면 같은 분기를 보는 기존 스텁 테스트도 함께 실패하므로, 두 테스트의 우열을 가려주는 실험은 아닙니다. 둘의 차이를 드러내려면 앞에서 말한 ’쓰는 키와 읽는 키가 어긋나는 경우’처럼 키를 어긋나게 만드는 변형이 필요한데, 이번 PR에서 실제로 돌려본 것은 if (false) 쪽입니다.


리뷰와 머지

github [#142] Created with Sketch.
Cover held ordering locks in cfworkersMerged
PR 1019seongmin36지난달에 머지

PR 본문에는 위에서 돌려본 체크를 그대로 적었습니다.

  • mise run check-each cfworkers — passes.
  • vitest run — 44 tests pass across 3 files.
  • Confirmed the test is not vacuous: replacing the lock check in processMessage() with if (false) makes it fail with expected true to be false, and it passes again once reverted.

PR #1019의 머지된 상태 캡처

이렇게 저의 두 번째 PR #1019가 머지되었고, Issue #879도 해결되었습니다.

후기

사실 처음 쓴 테스트는 이슈가 요구한 것과 다른 경로를 보고 있었습니다.
락이 이미 걸려 있는 경로가 아니라 락을 새로 거는 경로에 단언을 걸어두고, 통과하는 초록불만 보고 넘어갈 뻔했습니다. 앞에서 짚었듯 두 경로는 processMessage() 안에서 몇 줄 차이로 붙어 있어서, shouldProcess: true가 나오는 상황과 false가 나오는 상황을 코드만 보고 구분하기가 생각보다 어려웠습니다.

이번 작업에서 크게 남는 건 두 가지입니다.

하나는 “best-effort”라는 단어가 저장소의 성질에서 나온 결론이라는 점입니다.
Workers KV는 지역 간 전파에 최대 60초 이상 걸릴 수 있고, 락을 거는 get() → put() 사이를 묶어줄 CAS가 없습니다. 공식 문서와 소스 코드 주석이 같은 말을 하고 있다는 걸 확인하고 나서야 이슈 첫 문장이 제대로 읽혔습니다.

다른 하나는 스텁과 상태가 주는 확신의 크기가 다르다는 점입니다.
vi.spyOn()으로 get()의 답을 정해두면 분기 하나는 확실히 지킬 수 있지만, 그 분기로 가는 길까지 지켜주지는 않습니다. 일부러 깨뜨려 재검증하는 습관도 여기서 같이 얻었습니다.