Skip to content

feat(upstash): rewrite store as verified shard lists with batched I/O - #52

Closed
haydenshively wants to merge 3 commits into
mainfrom
feat/upstash-store-rewrite
Closed

feat(upstash): rewrite store as verified shard lists with batched I/O#52
haydenshively wants to merge 3 commits into
mainfrom
feat/upstash-store-rewrite

Conversation

@haydenshively

Copy link
Copy Markdown
Collaborator

Summary

Rewrites UpstashStore around a single storage format and two Lua scripts, fixing two live bugs in the RENAME-based writer:

  • Replay duplication — the Upstash client auto-retries on network error; a lost RPUSH response replayed the push, and RENAME published a list with a duplicate shard.
  • Tmp expiry mid-write — the tmp key's 60 s TTL was set once; a slow write let it expire, and RENAME published a truncated list.

Design

  • Every value is a Redis LIST of ≤ shardBytes (default 64 KiB) shards, each carrying wid|i|k| / wid|i| headers. Readers verify wid uniformity and index contiguity, so a read is either the old value or one complete new value.
  • Direct writes (WRITE_DIRECT script): whole value in one atomic EVALSHA; many keys packed into one /pipeline request.
  • Staged writes: per-writer tmp:<key>:<uuid> list, MULTI RPUSH + EXPIRE 60 per batch, then a PUBLISH script that verifies exact head + LLEN == k before RENAME. One restart (fresh uuid + wid) allowed before publish; never restaged after.
  • TTLs are absolute deadlines (PEXPIREAT) computed once, so replays cannot extend them.
  • Request bodies are measured exactly; responses are provisioned as floor(maxResponseBytes / shardBytes) elements per request.
  • Per-slot NOSCRIPT recovery (SCRIPT LOAD once, reissue only failed slots).

API

  • New plural mget / mset / mdelete; get / set / delete kept as deprecated one-liners until the plural Store contract lands.
  • UpstashStoreOptions gains shardBytes? and maxResponseBytes?. maxRequestBytes is now an exact bound rather than "value bytes plus headroom".
  • Storage format changed — existing cached values are read as misses. createOptimizedUpstashStore unchanged.
  • Codec, scripts, and packing helpers live in src/stores/upstash.internal.ts (not in the exports map).

Tests

test/helpers/fake-upstash.ts is an in-memory Upstash REST endpoint driven through the real @upstash/redis client (stubbed fetch), with virtual time, lazy expiry, fault injection, and delayed replay. 83 tests cover the PUBLISH return-code matrix, codec fuzzing, batching/budget assertions, NOSCRIPT recovery, deadline immutability under replay, freeze/thaw, the recombined-list case publication accepts but readers reject, and a randomized keyspace audit. upstash.integration.test.ts runs against a live database when UPSTASH_REDIS_REST_URL/_TOKEN are set — the Lua itself is only exercised there.

🤖 Generated with Claude Code

Every value is a Redis LIST of ≤ shardBytes self-indexed shards. Small values are written
atomically by a WRITE_DIRECT script, many per HTTP request; large values stage at a per-writer
tmp:<key>:<uuid> list (60 s TTL refreshed per push) and go live through a verifying PUBLISH
script. Readers check wid uniformity and index contiguity, so they observe either the old value
or one complete new one. TTLs are absolute deadlines, so client replays cannot extend them.

Fixes replay duplication and mid-write tmp expiry in the previous RENAME-based writer. Adds
plural mget/mset/mdelete with exact request-body measurement and pipeline packing; get/set/delete
remain as deprecated adapters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@haydenshively haydenshively changed the title feat(upstash)!: rewrite store as verified shard lists with batched I/O feat(upstash): rewrite store as verified shard lists with batched I/O Aug 26, 2026
@haydenshively haydenshively self-assigned this Aug 26, 2026
haydenshively and others added 2 commits August 26, 2026 16:30
Replace the self-referential measurement test with one that pins direct/staged routing to the
exact byte; add packing-pressure coverage; assert script result codes, attempt counts, and
identical replayed bodies instead of eventual readback alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move ./plans to a flat, tracked ./docs where the filename carries the kind and the package
version the document was written against (NNNNNN-<kind>-<slug>.md, two digits per semver
component). The version is a baseline, not a release, so the src/foo.ts:42 references inside a
brief stay resolvable and are never refreshed as code moves.

Each brief follows one spine: Intent, Context, Design, Scope & files, Verification, Open risks,
Notes, Derivation. Notes justifies the design as stated; Derivation holds what exists only
because the path was circuitous — alternatives declined, work deferred, findings surfaced along
the way. Prose is preserved as written; sections were renamed and reordered, not rewritten.

README.md registers the tib and ref prefixes, the frontmatter fields, and the spine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

@haydenshively

Copy link
Copy Markdown
Collaborator Author

Useful reference implementation, but closing because it's not directly relevant to anything the library needs. If downstream consumers want to build something like this, it's simple enough for them to expose mget/mset for themselves, and one-liine get/set wrappers for viem-dlc compatibility.

haydenshively added a commit that referenced this pull request Aug 28, 2026
Docs-only. Extracted from #52, whose code half was closed — so the doc
backfill never landed.

Adds two files:

- **`docs/000012-tib-paged-lenses-partial-results.md`** — the design
document for the paged-lens work that shipped in `07e49df` and
`0df02a9`: RETURN mode deleted, the `(U[] results, uint256[] skipped)`
lens contract and its four clauses, the client fill loop, and the
decision to respond in the shape the policy's `abi` declares. Written
against the `0.0.12` tree, so its `src/foo.ts:42` references resolve
there, not against `main`.
- **`docs/README.md`** — the conventions for this directory: the
`NNNNNN-<kind>-<slug>.md` filename, the version-as-baseline rule, TIB
frontmatter (`landed` present only once work ships), the fixed section
spine, and the Notes/Derivation split.

The index table lists only the document included here; the two `000016`
rows from the original branch would have been broken links. The
illustrative `000016-*` filenames inside the format examples are left
as-is — they're format illustrations, not links.

No source or test changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/morpho-org/viem-dlc/pull/53"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=3">
<img src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=3"
alt="Devin Review">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants