Unverified Commit 8a000b1c authored by Hong Minhee's avatar Hong Minhee
Browse files

Add optional CAS operation to KvStore

This commit introduces an optional `cas` (compare-and-swap) method to
the `KvStore` interface, enabling atomic updates. This is useful for
implementing optimistic locking and preventing lost updates in concurrent
environments.

The following implementations have been updated:
- `MemoryKvStore`: Implemented `cas` method.
- `DenoKvStore`: Implemented `cas` method using Deno KV's atomic operations.
- `WorkersKvStore`: Noted that Cloudflare Workers KV does not support atomic CAS
  operations.

Test cases have been added for the `MemoryKvStore` and `DenoKvStore`
implementations.
parent c4c4ec3c
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -8,6 +8,14 @@ Version 1.8.0

To be released.

 -  Key–value stores now optionally support CAS (compare-and-swap) operation
    for atomic updates.  This is useful for implementing optimistic locking
    and preventing lost updates in concurrent environments.

     -  Added optional `KvStore.cas()` method.
     -  Added `MemoryKvStore.cas()` method.
     -  Added `DenoKvStore.cas()` method.


Version 1.7.1
-------------
+77 −1
Original line number Diff line number Diff line
@@ -208,7 +208,8 @@ If the provided implementations don't meet your needs, you can create a custom
### Implement the `KvStore` interface

Create a class that implements the `KvStore` interface.  The interface defines
three methods: `~KvStore.get()`, `~KvStore.set()`, and `~KvStore.delete()`:
three methods: `~KvStore.get()`, `~KvStore.set()`, `~KvStore.delete()`, and
optionally `~KvStore.cas()`.

~~~~ typescript twoslash
import { KvStore, KvKey, KvStoreSetOptions } from "@fedify/fedify";
@@ -232,6 +233,17 @@ class MyCustomKvStore implements KvStore {
  async delete(key: KvKey): Promise<void> {
    // Implement delete logic
  }

  async cas(
    key: KvKey,
    expectedValue: unknown,
    newValue: unknown
  ): Promise<boolean> {
    // Implement compare-and-swap logic if needed
    // ---cut-start---
    return false;
    // ---cut-end---
  }
}
~~~~

@@ -404,6 +416,70 @@ async delete(key: KvKey): Promise<void> {
}
~~~~

### Implement `~KvStore.cas()` method (optional)

If your storage backend supports compare-and-swap (CAS) operations, you can
implement the `~KvStore.cas()` method. This method allows you to atomically
update a value only if it matches the expected value. This is useful for
implementing optimistic concurrency control.

~~~~ typescript twoslash
import type { KvStore, KvKey, KvStoreSetOptions } from "@fedify/fedify";
/**
 * A hypothetical storage interface.
 */
interface HypotheticalStorage {
  /**
   * A hypothetical method to compare and swap a value by key.
   * @param key The key to compare and swap.
   * @param expectedValue The expected value to match.
   * @param newValue The new value to set if the expected value matches.
   * @returns True if the operation was successful, false otherwise.
   */
  compareAndSwap(
    key: string,
    expectedValue: unknown,
    newValue: unknown
  ): Promise<boolean>;
}
class MyCustomKvStore implements KvStore {
  /**
   * A hypothetical storage backend.
   */
  storage: HypotheticalStorage = {
   async compareAndSwap(
     key: string,
     expectedValue: unknown,
     newValue: unknown
   ): Promise<boolean> { return false; }
  };
  private serializeKey(key: KvKey): string { return ""; }
  async get<T = unknown>(key: KvKey): Promise<T | undefined> {
    return undefined;
  }
  async set(
    key: KvKey,
    value: unknown,
    options?: KvStoreSetOptions
  ): Promise<void> { }
  async delete(key: KvKey): Promise<void> { }
// ---cut-before---
async cas(
  key: KvKey,
  expectedValue: unknown,
  newValue: unknown
): Promise<boolean> {
  const serializedKey = this.serializeKey(key);
  return await this.storage.compareAndSwap(
    serializedKey,
    expectedValue,
    newValue
  );
}
// ---cut-after---
}
~~~~

### Use your custom `KvStore`

That's it! You can now use your custom `KvStore` implementation with Fedify:
+1 −1
Original line number Diff line number Diff line
import { pascalCase } from "@es-toolkit/es-toolkit";
import { pascalCase } from "es-toolkit";
import metadata from "../deno.json" with { type: "json" };
import { getFieldName } from "./field.ts";
import type { PropertySchema, TypeSchema } from "./schema.ts";
+1 −1
Original line number Diff line number Diff line
@@ -20,7 +20,6 @@
  "imports": {
    "@cfworker/json-schema": "npm:@cfworker/json-schema@^4.1.1",
    "@cloudflare/workers-types": "npm:@cloudflare/workers-types@^4.20250529.0",
    "@es-toolkit/es-toolkit": "jsr:@es-toolkit/es-toolkit@^1.38.0",
    "@hugoalh/http-header-link": "jsr:@hugoalh/http-header-link@^1.0.2",
    "@multiformats/base-x": "npm:@multiformats/base-x@^4.0.1",
    "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0",
@@ -32,6 +31,7 @@
    "@std/yaml": "jsr:@std/yaml@^0.224.3",
    "asn1js": "npm:asn1js@^3.0.5",
    "byte-encodings": "npm:byte-encodings@^1.0.11",
    "es-toolkit": "jsr:@es-toolkit/es-toolkit@^1.39.5",
    "fast-check": "npm:fast-check@^3.22.0",
    "fetch-mock": "npm:fetch-mock@^12.5.2",
    "json-canon": "npm:json-canon@^1.0.1",
+13 −0
Original line number Diff line number Diff line
@@ -20,4 +20,17 @@ test("MemoryKvStore", async (t) => {
    await store.delete(["foo", "bar"]);
    assertEquals(await store.get(["foo", "bar"]), undefined);
  });

  await t.step("cas()", async () => {
    await store.set(["foo", "bar"], "foobar");
    assertEquals(await store.cas(["foo", "bar"], "bar", "baz"), false);
    assertEquals(await store.get(["foo", "bar"]), "foobar");
    assertEquals(await store.cas(["foo", "bar"], "foobar", "baz"), true);
    assertEquals(await store.get(["foo", "bar"]), "baz");
    await store.delete(["foo", "bar"]);
    assertEquals(await store.cas(["foo", "bar"], "foobar", "baz"), false);
    assertEquals(await store.get(["foo", "bar"]), undefined);
    assertEquals(await store.cas(["foo", "bar"], undefined, "baz"), true);
    assertEquals(await store.get(["foo", "bar"]), "baz");
  });
});
Loading