Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,23 @@ To be released.
its table with a D1 migration and pass `initialized: true` in production
to avoid an extra query per request.

- Added atomic multi-key snapshots, writes, deletions and conditional
updates to `WorkersD1KvStore`. Applications can now reserve a record
and update a shared budget together, with every comparison checked
before any change commits. Single-key and multi-key CAS share JSON
comparison and millisecond TTL rules; oversized calls reject instead
of splitting. [[#1361], [#1379], [#1432]]

Production D1 migrations must create both the entry table and its
`<tableName>_atomic` guard table, plus the expiry index, before using
`initialized: true`. Pass the D1 binding for fresh primary snapshots;
caller-owned sessions can read stale replicas. `WorkersKvStore`
remains without CAS or atomic multi-key support.

[#1135]: https://github.com/fedify-dev/fedify/issues/1135
[#1360]: https://github.com/fedify-dev/fedify/pull/1360
[#1379]: https://github.com/fedify-dev/fedify/issues/1379
[#1432]: https://github.com/fedify-dev/fedify/pull/1432

### @fedify/cli

Expand Down Expand Up @@ -429,6 +444,11 @@ To be released.
the index exists, writes whose expiration overflows PostgreSQL's timestamp
range fail atomically, including writes from older versions.
[[#1309], [#1334]]
- Fixed `PostgresKvStore` creating a redundant expiry index on PostgreSQL
18.7 when the existing index uses the documented `AT TIME ZONE`
spelling. Index detection now compares the expression itself rather
than the version-dependent index-column wrapper.
[[#1361], [#1379], [#1432]]

[index migration guide]: https://fedify.dev/manual/kv#postgresql-expiry-index
[#1309]: https://github.com/fedify-dev/fedify/issues/1309
Expand Down
18 changes: 18 additions & 0 deletions changes.d/cfworkers/atomic-kv-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
links:
'#1361': https://github.com/fedify-dev/fedify/issues/1361
'#1379': https://github.com/fedify-dev/fedify/issues/1379
'#1432': https://github.com/fedify-dev/fedify/pull/1432
---
- Added atomic multi-key snapshots, writes, deletions and conditional
updates to `WorkersD1KvStore`. Applications can now reserve a record
and update a shared budget together, with every comparison checked
before any change commits. Single-key and multi-key CAS share JSON
comparison and millisecond TTL rules; oversized calls reject instead
of splitting. [[#1361], [#1379], [#1432]]

Production D1 migrations must create both the entry table and its
`<tableName>_atomic` guard table, plus the expiry index, before using
`initialized: true`. Pass the D1 binding for fresh primary snapshots;
caller-owned sessions can read stale replicas. `WorkersKvStore`
remains without CAS or atomic multi-key support.
11 changes: 11 additions & 0 deletions changes.d/postgres/expiry-index-deparsing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
links:
'#1361': https://github.com/fedify-dev/fedify/issues/1361
'#1379': https://github.com/fedify-dev/fedify/issues/1379
'#1432': https://github.com/fedify-dev/fedify/pull/1432
---
- Fixed `PostgresKvStore` creating a redundant expiry index on PostgreSQL
18.7 when the existing index uses the documented `AT TIME ZONE`
spelling. Index detection now compares the expression itself rather
than the version-dependent index-column wrapper.
[[#1361], [#1379], [#1432]]
41 changes: 25 additions & 16 deletions claude-plugin/skills/fedify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,9 +458,17 @@ existing Deno applications throughout the 2.x series.
On Cloudflare Workers, use `WorkersD1KvStore` (backed by D1) rather than
`WorkersKvStore` (backed by Workers KV). Fedify's key–value workload is
balanced between reads and writes and needs strong consistency, and only
`WorkersD1KvStore` supports `cas()`. Pass the `D1Database` binding itself,
not a D1 session, and create its table with a D1 migration and
`initialized: true`, since the store is constructed per request.
`WorkersD1KvStore` implements `AtomicKvStore`. Pass the `D1Database` binding
for fresh primary reads; caller-owned sessions can serve stale `getMany()`
snapshots even after their first `first-primary` query. CAS always uses a
write transaction with a guard row, including absence-only CAS. Create both
the entry table and `<tableName>_atomic` guard table plus the expiry index
with a D1 migration before using `initialized: true`. The entry table needs
a single-column `key` primary index with `BINARY` collation. Follow the
complete schema and codec/TTL/batch limits in the
[KV manual][atomic support matrix]; a table name ending in `_atomic` is
reserved. Single and multi-key writes share the same protocol; oversized calls
reject instead of splitting.

For application transitions across several keys, require `AtomicKvStore`
from `@fedify/fedify` and narrow an existing `KvStore` with
Expand All @@ -482,17 +490,17 @@ for a shared backend. Separate calls do not form a transaction; uncertain
commit results need receipts or version checks, and ABA detection needs a
non-reused version.

`MemoryKvStore`, `SqliteKvStore`, and `PgliteKvStore` implement the full atomic
interface.
SQLite covers one configured table/database on a writable, normally locking
connection. Nonempty batches and ordinary mutations reject caller-owned
transactions and use one transaction; snapshots and mismatching CAS can reject
busy, including under rollback-journal reader contention. Configure busy
timeout on the connection; the KV store has no retry policy. Use decoded
JSON expectations, structural comparison, and the documented millisecond TTL
precision and resource limits. `SqliteMessageQueue` defaults to WAL on shared
files; concurrent WAL use needs a patched SQLite engine (Bun 1.2.22
SQLite 3.50.4 is affected by the WAL-reset bug). See the adapter documentation.
`MemoryKvStore`, `SqliteKvStore`, `PgliteKvStore`, and `WorkersD1KvStore`
implement the full atomic interface. SQLite covers one configured
table/database on a writable, normally locking connection. Nonempty batches
and ordinary mutations reject caller-owned transactions and use one
transaction; snapshots and mismatching CAS can reject busy, including under
rollback-journal reader contention. Configure busy timeout on the connection;
the KV store has no retry policy. Use decoded JSON expectations, structural
comparison, and the documented millisecond TTL precision and resource limits.
`SqliteMessageQueue` defaults to WAL on shared files; concurrent WAL use needs
a patched SQLite engine (Bun 1.2.22 SQLite 3.50.4 is affected by the WAL-reset
bug). See the adapter documentation.

For PGlite, share one normal owner and table across wrappers, require standard
JSONB/text-array codecs, and keep raw/manual transaction control and
Expand All @@ -506,8 +514,9 @@ worker-proxy support. Read the PGlite package README's synchronization policy.

The PostgreSQL, Redis, and MySQL atomic extensions are tracked separately in
issues #1364, #1365, and #1368; Deno KV and Netlify Blobs have no atomic adapter
child issue. `WorkersKvStore` lacks CAS. `WorkersD1KvStore` supports
single-key CAS; its atomic extension is tracked in #1379. Check the
child issue. `WorkersKvStore` lacks CAS. D1 operations cover one
configured table in one database, with JSON serialization and millisecond TTLs.
Check the
[atomic support matrix] and installed declarations before selecting a
production adapter.

Expand Down
79 changes: 61 additions & 18 deletions docs/manual/kv.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,14 +657,14 @@ database. Fedify writes to its key–value store about as often as it reads
from it, and some of its records, such as idempotency records and signature
nonces, need strong consistency. D1 fits this workload better than
[Cloudflare Workers KV], which is tuned for read-heavy workloads and is
eventually consistent. [`WorkersD1KvStore`] also supports
`~KvStore.cas()`, which some Fedify features need to work atomically.
eventually consistent. [`WorkersD1KvStore`] implements `AtomicKvStore`,
including single-key CAS, snapshot reads and conditional multi-key writes.

Best for
: Production use in Cloudflare Workers environments.

Pros
: Persistent storage, strong consistency, supports `~KvStore.cas()`.
: Persistent storage, primary reads and atomic multi-key operations.

Cons
: Only available in Cloudflare Workers runtime.
Expand All @@ -690,10 +690,16 @@ export default {

The store keeps its entries in a table named `fedify_kv` by default, which
you can change with the `tableName` option. Without `initialized: true`,
each store instance creates the table on its first use. Since you construct
the store for every request, that costs an extra D1 query per request, so
create the table with a [D1 migration][D1 migrations] in production and pass
`initialized: true`:
each store instance creates and checks its schema on first use. The names
`<tableName>_atomic` and `idx_<tableName>_expires` are reserved for the guard
table and expiry index; table names ending in `_atomic` are rejected.
The entry table must have a single-column `key` primary index using `BINARY`
collation, so differently cased keys remain distinct. Automatic initialization
rejects incompatible primary indexes. Key lookups, mutations, and listing
explicitly use binary comparisons even if the column declares another collation.

Create both tables and the index with a [D1 migration][D1 migrations] in
production, then pass `initialized: true` to avoid repeated schema queries:

~~~~ sql
CREATE TABLE IF NOT EXISTS fedify_kv (
Expand All @@ -702,12 +708,51 @@ CREATE TABLE IF NOT EXISTS fedify_kv (
expires INTEGER
);
CREATE INDEX IF NOT EXISTS idx_fedify_kv_expires ON fedify_kv (expires);
CREATE TABLE IF NOT EXISTS fedify_kv_atomic (
singleton INTEGER PRIMARY KEY,
cutoff INTEGER NOT NULL,
matched INTEGER NOT NULL
);
~~~~

Expired entries are never returned. Every `~KvStore.set()` and every
`~KvStore.cas()` that writes also purges up to 100 expired entries from the
table, so expired entries do not pile up while the store is in use. Entries
set without a TTL never expire and stay until they are deleted.
Atomic operations cover all keys in one configured table in one D1 database.
`getMany()` reads one snapshot and preserves input order, missing values and
duplicate keys. `setMany()`, `deleteMany()` and `casMany()` commit each batch
as a unit; `casMany()` compares every expectation before changing any entry.
Single-key `set()` and `cas()` use the same guard protocol. Each nonempty
set or CAS writes and removes a private guard row, including absence-only
CAS. A mismatch leaves all user values and TTLs unchanged. Backend errors
reject, and a lost response can leave commit status unknown. Re-read and
re-evaluate the application rule after a mismatch; include a non-reused
version in values if ABA changes must be detected.

Values use `JSON.stringify()`/`JSON.parse()`, including their coercions:
nonfinite numbers become `null`, and undefined object members are omitted.
Top-level `undefined` is only an absence/deletion sentinel in CAS; it cannot
be stored. Functions, symbols, cycles and `BigInt` values without a JSON
serialization reject. CAS compares the persisted JSON structurally, ignoring
object member order while retaining array order and types. Values deeper
than SQLite's 1,000-level JSON limit use exact serialized comparison instead.
External guard writes, triggers and custom constraints that alter the
protocol, and raw JSON rows with duplicate object members, are unsupported.

TTLs use one database-clock cutoff per operation and are truncated to integer
milliseconds. Nonpositive durations, including positive durations shorter
than one millisecond after truncation, expire immediately. Durations whose
absolute total exceeds `8.64e15` milliseconds, nonfinite totals, and calendar
durations requiring a reference date reject. Replacing without a TTL clears
the previous expiry. Successful sets and CAS also purge up to 100 entries
expired at the same cutoff; a mismatch does not run that cleanup.

Snapshots accept up to 256 input keys, counting duplicates, and at most
8 MiB of encoded keys and values. For maximum-size values, the byte budget
usually binds first. Mutations accept up to 128 entries and 16 MiB of encoded
keys, expectations and replacements combined. Each encoded key/value row
and each bound string is limited to 1,999,000 UTF-8 bytes. `getMany()` and
`deleteMany()` keys also have a 1,999,000-byte aggregate encoded-key limit, and
the final escaped JSON key payload must fit the same limit. Oversized
operations reject without splitting; backend or runtime limits can also
reject smaller calls.

> [!WARNING]
> Pass the `D1Database` binding itself (e.g., `env.DB`) to get strongly
Expand Down Expand Up @@ -1008,10 +1053,10 @@ check or an operation receipt before repeating effects.

### Current support

`MemoryKvStore`, `SqliteKvStore`, and `PgliteKvStore` implement the full
`AtomicKvStore` contract.
Existing single-key CAS support does not imply atomic multi-key support.
The linked issues track separate adapter implementations:
`MemoryKvStore`, `SqliteKvStore`, `PgliteKvStore`, and `WorkersD1KvStore`
implement the full `AtomicKvStore` contract. Existing single-key CAS support
does not imply atomic multi-key support. The linked issues track separate
adapter implementations:

| Implementation | `AtomicKvStore` | Status |
| --------------------- | --------------- | ------------------------------------------------------------------------------------------------- |
Expand All @@ -1024,13 +1069,11 @@ The linked issues track separate adapter implementations:
| `DenoKvStore` | No | No atomic adapter child issue; deprecated since 2.5.0. |
| `NetlifyBlobsKvStore` | No | Single-key CAS only; no atomic adapter child issue. |
| `WorkersKvStore` | No | No CAS; cannot implement this contract with Workers KV. |
| `WorkersD1KvStore` | No | Base adapter [PR #1360] supports single-key CAS; atomic follow-up is [#1379]. |
| `WorkersD1KvStore` | Yes | One configured table in one D1 database; see its codec, TTL, size and session restrictions above. |

[PR #1360]: https://github.com/fedify-dev/fedify/pull/1360
[#1364]: https://github.com/fedify-dev/fedify/issues/1364
[#1365]: https://github.com/fedify-dev/fedify/issues/1365
[#1368]: https://github.com/fedify-dev/fedify/issues/1368
[#1379]: https://github.com/fedify-dev/fedify/issues/1379


Bounding how long cache entries live
Expand Down
70 changes: 62 additions & 8 deletions packages/cfworkers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,20 @@ export default {
`WorkersD1KvStore` is a key–value store implementation for
[Cloudflare Workers] that uses [Cloudflare D1]. D1 is strongly consistent,
which suits Fedify's balanced read and write workload, and
`WorkersD1KvStore` supports the atomic `cas()` operation. It is the
`WorkersD1KvStore` implements `AtomicKvStore`, including atomic snapshots and
conditional multi-key writes. It is the
recommended key–value store for Cloudflare Workers applications.

Without `initialized: true`, each store instance creates its table
(`fedify_kv` by default, configurable with the `tableName` option) on its
first use, which costs an extra D1 query per request. In production, create
the table with [D1 migrations] and pass `initialized: true`, as in the example
above:
Without `initialized: true`, each store instance creates and checks its schema
on first use. The entry table is `fedify_kv` by default, configurable with
`tableName`; `<tableName>_atomic` and `idx_<tableName>_expires` are reserved
auxiliary names. The entry table must have a single-column `key` primary
index using `BINARY` collation, so differently cased keys remain distinct.
Automatic initialization rejects incompatible primary indexes. Key lookups,
mutations, and listing explicitly use binary comparisons even if the column
declares another collation. Table names ending in `_atomic` are rejected. In
production, create both tables and the index with [D1 migrations], then pass
`initialized: true` to avoid repeating schema queries per request:

~~~~ sql
CREATE TABLE IF NOT EXISTS fedify_kv (
Expand All @@ -88,10 +94,58 @@ CREATE TABLE IF NOT EXISTS fedify_kv (
expires INTEGER
);
CREATE INDEX IF NOT EXISTS idx_fedify_kv_expires ON fedify_kv (expires);
CREATE TABLE IF NOT EXISTS fedify_kv_atomic (
singleton INTEGER PRIMARY KEY,
cutoff INTEGER NOT NULL,
matched INTEGER NOT NULL
);
~~~~

Pass the `D1Database` binding itself rather than a D1 session: sessions only
provide sequential consistency and can read stale data from a replica.
Pass the `D1Database` binding itself for fresh primary reads. A caller-owned
D1 session provides sequential consistency, but `get()` and `getMany()` may
read stale replicas. `first-primary` affects only the session's first query;
it does not pin later reads to the primary. CAS uses a write transaction
with either input. The adapter keeps the caller's session and does not call
`withSession()` on its behalf.

Atomic operations cover all keys in one configured table in one D1 database.
`getMany()` reads one snapshot and preserves input order, missing values and
duplicate keys. `setMany()`, `deleteMany()` and `casMany()` commit each batch
as a unit; `casMany()` compares every expectation before changing any entry.
Single-key `set()` and `cas()` use the same guard protocol. Each nonempty
set or CAS writes and removes a private guard row, including absence-only
CAS. A mismatch leaves all user values and TTLs unchanged. Backend errors
reject, and a lost response can leave commit status unknown. Re-read and
re-evaluate the application rule after a mismatch; include a non-reused
version in values if ABA changes must be detected.

Values use `JSON.stringify()`/`JSON.parse()`, including their coercions:
nonfinite numbers become `null`, and undefined object members are omitted.
Top-level `undefined` is only an absence/deletion sentinel in CAS; it cannot
be stored. Functions, symbols, cycles and `BigInt` values without a JSON
serialization reject. CAS compares the persisted JSON structurally, ignoring
object member order while retaining array order and types. Values deeper
than SQLite's 1,000-level JSON limit use exact serialized comparison instead.
External guard writes, triggers and custom constraints that alter the
protocol, and raw JSON rows with duplicate object members, are unsupported.

TTLs use one database-clock cutoff per operation and are truncated to integer
milliseconds. Nonpositive durations, including positive durations shorter
than one millisecond after truncation, expire immediately. Durations whose
absolute total exceeds `8.64e15` milliseconds, nonfinite totals, and calendar
durations requiring a reference date reject. Replacing without a TTL clears
the previous expiry. Successful sets and CAS also purge up to 100 entries
expired at the same cutoff; a mismatch does not run that cleanup.

Snapshots accept up to 256 input keys, counting duplicates, and at most
8 MiB of encoded keys and values. For maximum-size values, the byte budget
usually binds first. Mutations accept up to 128 entries and 16 MiB of encoded
keys, expectations and replacements combined. Each encoded key/value row
and each bound string is limited to 1,999,000 UTF-8 bytes. `getMany()` and
`deleteMany()` keys also have a 1,999,000-byte aggregate encoded-key limit, and
the final escaped JSON key payload must fit the same limit. Oversized
operations reject without splitting; backend or runtime limits can also
reject smaller calls.

[Cloudflare D1]: https://developers.cloudflare.com/d1/
[D1 migrations]: https://developers.cloudflare.com/d1/reference/migrations/
Expand Down
Loading
Loading