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
73 changes: 70 additions & 3 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ jobs:
ports:
- 5432:5432
redis:
image: redis
image: redis:7.2.17
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
Expand Down Expand Up @@ -171,7 +171,7 @@ jobs:
ports:
- 5432:5432
redis:
image: redis
image: redis:7.2.17
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
Expand Down Expand Up @@ -235,7 +235,7 @@ jobs:
ports:
- 5432:5432
redis:
image: redis
image: redis:7.2.17
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
Expand Down Expand Up @@ -270,6 +270,73 @@ jobs:
env:
AMQP_ORDERING_TEST: "true"

test-redis-atomic:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
redis: ["7.2.17", "7.4.12", "8.10.3"]
services:
redis:
image: redis:${{ matrix.redis }}
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 5
redis-admin:
image: redis:${{ matrix.redis }}
ports:
- 6380:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 5
env:
FEDIFY_REDIS_REQUIRE_ATOMIC_TESTS: "true"
REDIS_URL: redis://127.0.0.1:6379
REDIS_ATOMIC_ADMIN_URL: redis://127.0.0.1:6380
REDIS_CLUSTER_NODES: 127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002
REDIS_ATOMIC_CLUSTER_ADMIN_NODES: 127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002
REDIS_ATOMIC_MIGRATION_NODES: 127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-mise
- name: Start a dedicated three-primary Redis Cluster
env:
REDIS_IMAGE: redis:${{ matrix.redis }}
run: |
for port in 7000 7001 7002; do
docker run --detach --name "redis-cluster-$port" --network host \
"$REDIS_IMAGE" redis-server --bind 127.0.0.1 --port "$port" \
--cluster-enabled yes --cluster-config-file /data/nodes.conf \
--cluster-announce-ip 127.0.0.1 --save "" --appendonly no
for _ in $(seq 1 30); do
if docker exec "redis-cluster-$port" redis-cli -p "$port" ping | grep -q PONG; then
break
fi
sleep 1
done
docker exec "redis-cluster-$port" redis-cli -p "$port" ping | grep -q PONG
done
docker exec redis-cluster-7000 redis-cli --cluster create \
127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 \
--cluster-replicas 0 --cluster-yes
for port in 7000 7001 7002; do
for _ in $(seq 1 30); do
if docker exec "redis-cluster-$port" redis-cli -p "$port" cluster info | grep -q 'cluster_state:ok'; then
break
fi
sleep 1
done
docker exec "redis-cluster-$port" redis-cli -p "$port" cluster info | grep -q 'cluster_state:ok'
done
# The task runs Deno, Node.js and Bun sequentially on exclusive fixtures.
- run: mise run test-each redis

test-cfworkers:
runs-on: ubuntu-latest
env:
Expand Down
27 changes: 27 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,10 +429,37 @@ To be released.
the index exists, writes whose expiration overflows PostgreSQL's timestamp
range fail atomically, including writes from older versions.
[[#1309], [#1334]]
- Fixed duplicate expiry-index creation when reinitializing `PostgresKvStore`
with an existing `AT TIME ZONE` index on PostgreSQL 18.7. Index detection
now checks the stored expression rather than index-definition syntax,
preserving both documented `timezone()` and `AT TIME ZONE` forms.
[[#1434]]

[index migration guide]: https://fedify.dev/manual/kv#postgresql-expiry-index
[#1309]: https://github.com/fedify-dev/fedify/issues/1309
[#1334]: https://github.com/fedify-dev/fedify/pull/1334
[#1434]: https://github.com/fedify-dev/fedify/pull/1434

### @fedify/redis

- Added atomic snapshots and multi-key writes, deletes, and compare-and-swap
through `RedisKvStore.getMany()`, `setMany()`, `deleteMany()`, and
`casMany()`. Batches support standalone Redis and one Redis Cluster hash
slot on official Redis 7.2.17, 7.4.12, and 8.10.3; other releases reject
nonempty batches until audited and added in a Fedify update.
These restrictions preserve the audited atomicity guarantees; select a
supported Redis release for nonempty batches. Existing keys are not
relocated. [[#1361], [#1365], [#1434]]

Storing `undefined` or a top-level value that JSON cannot encode now
rejects instead of writing undecodable bytes. CAS deletions with a
non-null TTL reject even when the comparison would mismatch. CAS
success results now also handle ioredis `stringNumbers` clients. Remove
TTLs from CAS deletions when upgrading affected calls. Ordinary
single-key methods retain their existing Redis version and TTL range
support.

[#1365]: https://github.com/fedify-dev/fedify/issues/1365

### @fedify/sqlite

Expand Down
9 changes: 9 additions & 0 deletions changes.d/postgres/expiry-index-expression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
links:
'#1434': https://github.com/fedify-dev/fedify/pull/1434
---
- Fixed duplicate expiry-index creation when reinitializing `PostgresKvStore`
with an existing `AT TIME ZONE` index on PostgreSQL 18.7. Index detection
now checks the stored expression rather than index-definition syntax,
preserving both documented `timezone()` and `AT TIME ZONE` forms.
[[#1434]]
22 changes: 22 additions & 0 deletions changes.d/redis/atomic-kv-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
links:
'#1361': https://github.com/fedify-dev/fedify/issues/1361
'#1365': https://github.com/fedify-dev/fedify/issues/1365
'#1434': https://github.com/fedify-dev/fedify/pull/1434
---
- Added atomic snapshots and multi-key writes, deletes, and compare-and-swap
through `RedisKvStore.getMany()`, `setMany()`, `deleteMany()`, and
`casMany()`. Batches support standalone Redis and one Redis Cluster hash
slot on official Redis 7.2.17, 7.4.12, and 8.10.3; other releases reject
nonempty batches until audited and added in a Fedify update.
These restrictions preserve the audited atomicity guarantees; select a
Comment thread
dahlia marked this conversation as resolved.
Resolved
supported Redis release for nonempty batches. Existing keys are not
relocated. [[#1361], [#1365], [#1434]]

Storing `undefined` or a top-level value that JSON cannot encode now
rejects instead of writing undecodable bytes. CAS deletions with a
non-null TTL reject even when the comparison would mismatch. CAS
success results now also handle ioredis `stringNumbers` clients. Remove
TTLs from CAS deletions when upgrading affected calls. Ordinary
single-key methods retain their existing Redis version and TTL range
support.
26 changes: 20 additions & 6 deletions claude-plugin/skills/fedify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,8 +482,8 @@ 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.
`MemoryKvStore`, `RedisKvStore`, `SqliteKvStore`, and `PgliteKvStore`
implement the atomic interface within their documented scopes.
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
Expand All @@ -504,10 +504,24 @@ engine insertion failures roll back, while mismatch does not prove proposed
writes would fit. Do not infer crash durability, browser persistence, or
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
Redis batches currently require official Redis 7.2.17,
7.4.12, or 8.10.3, with no debugger or module command hooks; other releases
reject nonempty batches until audited and added in a Fedify update. The version
guard does not authenticate forks or modified engines. On Cluster, all final
physical keys must share one slot in database zero, including ioredis's client
prefix. Choose an explicit hash tag for a new namespace; existing keys are not
automatically moved. Each batch request is limited to 128 entries and 1 MiB of
key, encoded value/expectation, and argument data. TTLs round up to seconds
with a minimum of one second and batch maximum of 2,147,483,647 seconds. With
`JsonCodec`, CAS compares encoded bytes, so object property order matters.
Grant read/write key access for `EVAL`, including snapshots. Snapshot reads
route to primaries, but Cluster `list()` retains its existing `SCAN` coverage
limitation. Redis persistence/failover and ioredis replays remain separate from
per-call atomicity; a later CAS mismatch does not prove a prior attempt never
committed. The PostgreSQL and MySQL atomic extensions are tracked separately in
issues #1364 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
[atomic support matrix] and installed declarations before selecting a
production adapter.

Expand Down
79 changes: 73 additions & 6 deletions docs/manual/kv.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,68 @@ const federation = createFederation<void>({
});
~~~~

#### Atomic operations

Since Fedify 2.5.0, `RedisKvStore` implements `AtomicKvStore` on official Redis
7.2.17, 7.4.12, and 8.10.3. Other releases reject nonempty batch calls before
reading or writing. Supporting another Redis release requires a new audit and
Fedify update; ordinary single-key methods keep their existing compatibility.
Modified engines, forks, script debugging, and module command hooks are not
supported atomic configurations, and the version guard does not authenticate
the server.

On standalone Redis, batches cover one selected database on the primary. On
Cluster, all effective physical keys must share a slot in database zero. For a
new namespace, the existing `keyPrefix` option can select that scope:

~~~~ typescript twoslash
import { RedisKvStore } from "@fedify/redis";
import { Cluster } from "ioredis";

const redis = new Cluster([{ host: "127.0.0.1", port: 7000 }]);
const store = new RedisKvStore(redis, { keyPrefix: "{inventory}::" });
const values = await store.getMany([["available"], ["reserved"]]);
~~~~

Keys are not automatically tagged or moved. Changing a prefix selects a new
namespace and needs an application migration for existing data. The first `{`
and following `}` select a hash tag only if the pair is nonempty; an empty pair
hashes the whole key. A tag in the ioredis client's own `keyPrefix` can take
precedence over a tag in the adapter's `keyPrefix`. Cross-slot calls reject,
including different slots on the same master; they never split into separate
operations. Missing or expired keys can cause an entire batch to reject during
resharding.

Each request is limited to 128 entries and 1 MiB of effective key, encoded
values and expectations, and argument data, excluding the fixed script and
protocol framing. Batch TTLs round up to whole seconds, with a minimum of one
second and maximum of 2,147,483,647 seconds. Zero and negative TTLs therefore
keep a value for one second. Omitting a TTL clears the previous expiry.
Single-key TTLs keep their existing range handling. CAS compares encoded bytes;
with the default JSON codec, object property order matters. Values must
round-trip through their codec; `undefined` is never stored (as a CAS
`newValue` it deletes the key), while `null` remains a value.

Before changing keys, the scripts read every target and check each write
command's permissions.
They require `EVAL`, `GET` or `EXISTS`, and the applicable `SET`, `SETEX`, or
`DEL` access. Redis checks `EVAL`'s read/write key access before entering the
script, so key patterns must grant both read and write access even for
`getMany()` snapshots. Snapshots route to the primary despite Cluster
`scaleReads`. The existing Cluster prefix-listing `SCAN` limitation remains:
listing does not reliably visit every node, and `getMany()` does not snapshot
`list()`.

Configured memory pressure can reject batch deletes as well as writes;
ordinary `delete()` remains available. A successful batch is not a persistence
or failover guarantee. ioredis can replay requests after disconnection, so even
a CAS mismatch reply does not prove an earlier attempt never committed.
Re-read and re-evaluate application rules after a mismatch or uncertain
response. See the [Redis package guide]
for codecs, supported configurations, limits, and retry behavior.

[`RedisKvStore`]: https://jsr.io/@fedify/redis/doc/kv/~/RedisKvStore
[Redis package guide]: https://github.com/fedify-dev/fedify/tree/main/packages/redis#atomic-keyvalue-operations

### [`PostgresKvStore`]

Expand Down Expand Up @@ -1008,16 +1069,16 @@ 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`, `RedisKvStore`, `SqliteKvStore`, and `PgliteKvStore`
implement `AtomicKvStore` within their documented scopes. Existing single-key
CAS support in other adapters does not imply atomic multi-key support.
The linked issues track their implementations:

| Implementation | `AtomicKvStore` | Status |
| --------------------- | --------------- | ------------------------------------------------------------------------------------------------- |
| `MemoryKvStore` | Yes | Development and testing only; one instance. |
| `PostgresKvStore` | No | Planned in [#1364]. |
| `RedisKvStore` | No | Planned in [#1365]. |
| `RedisKvStore` | Yes | Official Redis 7.2.17, 7.4.12, or 8.10.3; standalone or one Cluster slot; 128 entries/1 MiB. |
| `PgliteKvStore` | Yes | Since 2.5.0; one normal PGlite owner and table, JSONB comparison. |
| `SqliteKvStore` | Yes | One configured table/database; writable handles, JSON values; see [SQLite scope](#sqlitekvstore). |
| `MysqlKvStore` | No | Planned in [#1368]. |
Expand All @@ -1028,7 +1089,6 @@ The linked issues track separate adapter implementations:

[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

Expand Down Expand Up @@ -1622,6 +1682,13 @@ when no new TTL is supplied. Reject unsupported batches before any mutation;
do not split calls to fit backend limits. If a later statement fails, roll
back all earlier writes or deletes.

`RedisKvStore` uses bounded scripts and a restricted engine list because Redis
scripts do not undo earlier writes when a later command fails. Its complete
input encoding, execution-time key reads, command-permission checks, and script
bounds are part of the all-or-nothing argument. An isolated `EVAL` or a
`MULTI`/`EXEC` sequence alone does not establish this contract. Keep those Redis
constraints when extending its implementation.

#### Implement `~AtomicKvStore.casMany()` method

Protect the complete read set against competing clients, including absent and
Expand Down
28 changes: 15 additions & 13 deletions packages/postgres/src/kv.index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,21 @@ test(
assert.equal(initial.length, 1);
assert.equal(initial[0].indisvalid, true);
assert.match(initial[0].definition, /timezone\('UTC'/);
const [{ name }] = await sql`
SELECT c.relname AS name FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = ${tableName}::regclass AND NOT i.indisprimary
`;
await sql`DROP INDEX ${sql(name)}`;
await manualIndex(sql, tableName, true);
statements.length = 0;
await new PostgresKvStore(sql, {
tableName: `public.${tableName}`,
}).initialize();
assert.equal((await indexes(sql, tableName)).length, 1);
assert(!statements.some((q) => /LOCK TABLE|CREATE INDEX/.test(q)));
for (const atTimeZone of [false, true]) {
const [{ name }] = await sql`
SELECT c.relname AS name FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = ${tableName}::regclass AND NOT i.indisprimary
`;
await sql`DROP INDEX ${sql(name)}`;
await manualIndex(sql, tableName, atTimeZone);
statements.length = 0;
await new PostgresKvStore(sql, {
tableName: `public.${tableName}`,
}).initialize();
assert.equal((await indexes(sql, tableName)).length, 1);
assert(!statements.some((q) => /LOCK TABLE|CREATE INDEX/.test(q)));
}
} finally {
await store.drop();
await sql.end();
Expand Down
4 changes: 3 additions & 1 deletion packages/postgres/src/kv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,8 @@ export class PostgresKvStore implements KvStore {
WHERE c.oid = (SELECT typrelid FROM pg_type
WHERE oid = pg_typeof(NULL::${sql(this.#tableName)})::oid)
`;
// Compare the stored expression, not index-definition syntax whose
// enclosing parentheses can change between PostgreSQL minor releases.
const indexed = async () => {
const [result] = await sql`
SELECT EXISTS (
Expand All @@ -409,7 +411,7 @@ export class PostgresKvStore implements KvStore {
AND NOT i.indisunique AND i.indnatts = 1
AND a.amname = 'btree' AND i.indkey[0] = 0
AND o.opcdefault AND o.opcintype = 'timestamptz'::regtype
AND pg_get_indexdef(i.indexrelid, 1, false) IN (
AND pg_get_expr(i.indexprs, i.indrelid, false) IN (
${"timezone('UTC'::text, (timezone('UTC'::text, created) + ttl))"},
${"(((created AT TIME ZONE 'UTC'::text) + ttl) AT TIME ZONE 'UTC'::text)"}
)
Expand Down
Loading
Loading