atproto pds in zig pds.zat.dev
pds atproto
zds docs spindle-cache-proposal.md
5.4 kB

spindle-cache — an actions/cache for tangled CI, blobs on your own PDS #

handoff spec, written 2026-08-08 from the stream operations session (tangled.org/zat.dev/stream — see its docs/deploying.md and .tangled/workflows/ for the motivating workloads). This is its own project; it landed here because zds is the PDS whose blob store makes the design honest. Whoever picks this up: everything below is design-complete enough to start, and nothing below is sacred.

the problem #

Tangled spindle CI has no artifact/workspace cache. Every push pays the full cost of anything the workflow computes: for zig projects that means the ReleaseSafe compile (~10 min for stream) on every single run. GitHub solved this with actions/cache years ago; spindle has only:

  • Nixery layer caching (nixery engine) — dependency images, not build outputs
  • Nix binary caches (microvm engine) — nix store paths, not your zig-cache

There is no key-addressed "save this directory, restore it next run".

the insight #

Two existing mechanisms compose into the whole feature:

  1. Spindle's plugin system is the nix registry. Any workflow can pull a CLI from any flake ref:

    dependencies:
      git+https://tangled.org/@zat.dev/spindle-cache:
        - spindle-cache
    

    Publishing a repo with a flake IS publishing an action.

  2. The PDS is the storage, and eviction falls out of atproto's data model. The spindle docs' own release recipe already uploads blobs via com.atproto.repo.uploadBlob and pins them with sh.tangled.repo.artifact records. A cache entry is the same shape: a record keyed by cache key, referencing tarball blob(s). PDS blob GC deletes unreferenced blobs — so overwriting a key's record automatically releases the old blobs. The eviction policy nobody wants to write is free.

UX (mirror actions/cache, people know it) #

steps:
  - name: restore build cache
    command: spindle-cache restore --key "zig-$(zig version)-$(sha256sum build.zig.zon | cut -c1-16)" --path .zig-cache
  - name: build
    command: zig build test
  - name: save build cache
    command: spindle-cache save --key "..." --path .zig-cache
  • restore: resolve the record (getRecord by rkey = sanitized key) on the configured PDS, fetch blob(s), unpack to --path. Miss = exit 0, empty dir. Optional --restore-keys prefix fallback like actions/cache.
  • save: tar + zstd the path, uploadBlob (chunked, see limits), then putRecord with the blob refs. Last writer wins; races are harmless for caches.
  • Auth: ATP_IDENTIFIER + ATP_APP_PASSWORD pipeline secrets — the exact pattern the spindle artifact-publishing recipe documents.

the lexicon (strawman) #

dev.zat.spindle.cache            (or whatever namespace fits)
  key:        string   (the full cache key, also sanitized into rkey)
  createdAt:  datetime
  toolVersion:string
  chunks:     blob[]   (ordered zstd tarball chunks)
  totalBytes: integer
  pathHint:   string   (informational: what was cached)

rkey = a hash of the key so arbitrary key strings stay valid. One record per key; putRecord replaces → old chunks unreference → PDS GC collects.

constraints, named honestly #

  • Blob size caps. Hosted PDSes cap blobs (tens of MB typical). The CLI must chunk (e.g. 32 MiB chunks, blob[] in the record). On zds the cap is a flag (blob_upload_limit) — running your own PDS makes big caches a config decision, which is exactly the dogfooding angle: zds is the reference deployment for this tool.
  • Caches are public. PDS blobs are world-readable. Fine (good, even) for OSS build caches; disqualifying for anything containing secrets. README must say so loudly.
  • Bandwidth. A ~300 MB zig-cache round-trips through the PDS each run. Worth it against a 10-min compile; measure and document the break-even. Chunked parallel fetch helps.
  • PDS blob GC timing varies by implementation — verify zds actually collects unreferenced blobs (if not, that's a zds issue this project smokes out — also valuable).
  • Repo-level trust: anyone can publish records claiming any key; the consumer only ever reads from ITS OWN configured identity's repo, so there is no cross-account cache-poisoning surface as long as the CLI never resolves other identities.

implementation notes #

  • Language: go or zig, single static binary, flake packaging it (see tangled's own repos for flake conventions). go is faster to ship; zig keeps the toolchain story consistent with the rest of zat.dev.
  • ~600 lines: tar/zstd, chunked uploadBlob with retry, putRecord/ getRecord, key sanitization. No daemon, no state.
  • First adopter: stream's .tangled/workflows/ci.yml (restore/save around zig build test -Doptimize=ReleaseSafe) and later its admission workflow. Expected effect: ReleaseSafe test step drops from ~10 min to incremental-compile time on warm keys.
  • Name ideas: spindle-cache (obvious), stash, hoard.

why this is ecosystem-forward #

Every tangled CI user has this problem and none of them has a cache. This ships as: a repo + a flake + a lexicon, storage the user already owns, zero new infrastructure, and the first real demonstration that "actions marketplace" on tangled is just flakes + PDS records. The feature request we almost filed becomes a thing the ecosystem gets to adopt instead.