Skip to content

Add a Netlify Blobs-backed KvStore #1010

Description

@dahlia

Background

@fedify/netlify currently connects Fedify’s MessageQueue API to Netlify Async Workloads, but users must bring a separate KvStore. The documentation uses PostgresKvStore with Netlify Database because messages with an orderingKey require an orderingKv that supports cas().

That setup is correct, but a Postgres database is a poor fit for small Fedify applications whose persistent state consists mostly of actor keys, follower records, delivery bookkeeping, and short-lived deduplication markers.

I encountered this while running a mostly static Fedify-backed blog on Netlify. During the current billing period, Netlify Database consumed 205.85 GB-hours and 2,058.5 of the project’s 2,343.8 credits. ActivityPub requests arrived often enough that the database rarely reached Netlify’s five-minute inactivity threshold. The data itself is tiny.

Netlify Blobs is designed for frequently read, infrequently written data and can act as a simple key-value store. It persists site-wide data across deploys and is available from both Functions and Edge Functions. I propose adding a NetlifyBlobsKvStore implementation to @fedify/netlify.

Proposed API

The adapter could accept a Netlify Blobs Store, or a structurally compatible subset, rather than creating the store itself:

import { getStore } from "@netlify/blobs";
import {
  NetlifyBlobsKvStore,
  NetlifyMessageQueue,
} from "@fedify/netlify";

const store = getStore({
  name: "fedify",
  consistency: "strong",
});

const kv = new NetlifyBlobsKvStore(store);
const queue = new NetlifyMessageQueue({
  client: new AsyncWorkloadsClient(),
  orderingKv: kv,
});

Accepting a store object would keep @netlify/blobs optional for users who only need NetlifyMessageQueue. It would also make the adapter easier to test without a live Netlify project.

The implementation would encode structured KvKey values as blob keys, store values as JSON, use blob metadata for logical expiration, and implement prefix scans with Store.list().

Conditional writes

Current versions of @netlify/blobs support atomic conditional writes through onlyIfNew and ETag-based onlyIfMatch. These operations appear sufficient to implement per-key cas():

  • cas(key, undefined, value) can use onlyIfNew.
  • cas(key, expected, value) can perform a strongly consistent getWithMetadata(), compare the stored value, and write with onlyIfMatch.
  • cas(key, expected, undefined) cannot use a conditional delete because the public Blobs API does not expose one. It may need an ETag-guarded tombstone instead.

A CAS-capable implementation matters here. NetlifyMessageQueue uses CAS to reserve and advance ordering sequences, and Fedify also uses it for task deduplication when the queue does not provide native deduplication.

The tombstone case needs more design work. Netlify Blobs has no server-side TTL, so both expired values and conditional deletion markers require logical expiration and eventual reclamation. Cleanup must not delete a value that replaced an expired entry after the cleanup read. We should determine whether the underlying API can support conditional deletion or whether the adapter needs a different representation.

setJSON() did not reliably pass conditional-write headers until @netlify/blobs 10.7.12, so the adapter should either require that version or serialize JSON explicitly and use Store.set().

Expected work

  • Add NetlifyBlobsKvStore to @fedify/netlify.
  • Implement get(), set(), delete(), list(), TTL handling, and, if the deletion semantics can be made safe, cas().
  • Use strong consistency for operations that participate in CAS.
  • Reject or document encoded keys that exceed Netlify Blobs’ 600-byte key limit.
  • Cover concurrent creates and updates, logical expiration, prefix listing, tombstones, and pagination in unit tests.
  • Add a Netlify Dev integration test for persistence and conditional writes.
  • Document a database-free setup using NetlifyBlobsKvStore with NetlifyMessageQueue.
  • Explain the remaining consistency and cleanup tradeoffs in the API documentation.

Metadata

Metadata

Assignees

Labels

Fields

Priority

None yet

Effort

None yet

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions