626 lines
31 KiB
Markdown
626 lines
31 KiB
Markdown
# API-Mediated Access (Redis Lockdown)
|
|
|
|
## Status
|
|
|
|
**Proposed** — design accepted; implementation pending. Builds on
|
|
[uuid-rooted-metadata.md](uuid-rooted-metadata.md), which assumes meta-core
|
|
is the single writer to the metadata keyspace. That assumption is currently
|
|
violated by meta-sort, and this doc is the plan to put it back on solid
|
|
ground.
|
|
|
|
## Motivation
|
|
|
|
meta-core was designed as the sidecar that mediates every metadata read and
|
|
write — a single point that owns the Redis schema, enforces invariants
|
|
(reverse index, canonical_cid reconciliation, schema-version sentinel),
|
|
and presents a stable HTTP contract. Other services were meant to talk to
|
|
meta-core, not to Redis.
|
|
|
|
In practice, meta-sort opens its own Redis connection and writes
|
|
`file:<hashId>/<property>` keys with raw pipeline SETs
|
|
(`packages/meta-sort/packages/meta-sort-core/src/kv/RedisClient.ts:269-296`).
|
|
It picked up this shortcut early, before meta-core had a complete write
|
|
API, and the shortcut stuck.
|
|
|
|
This bypass is a real problem in three ways:
|
|
|
|
1. **Schema invariants leak.** When meta-core landed the UUID-rooted schema
|
|
([uuid-rooted-metadata.md](uuid-rooted-metadata.md)), meta-sort kept
|
|
writing midhash-rooted entries. The result was *dual roots per file* —
|
|
one set of keys at the watcher's UUID, another at meta-sort's midhash.
|
|
`file:__index__` doubled in size. The bug was invisible to meta-sort
|
|
(its writes succeeded) but broke the reverse-index resolution
|
|
downstream services rely on.
|
|
|
|
2. **Schema evolution is impossible.** Changing the Redis layout requires
|
|
coordinating across every service that touches it. The UUID-root
|
|
migration would have been a single-PR change if writes already routed
|
|
through the API.
|
|
|
|
3. **Self-hosted distributed deployment widens the threat model.** Peers
|
|
are run by different operators. Plugin authors may ship their own
|
|
services. We can't expect every binary in the network to honour
|
|
undocumented conventions. A buggy or careless service that connects
|
|
to Redis and writes the wrong key shape can corrupt the schema for
|
|
everybody on that node.
|
|
|
|
The third point is the one that flipped the design from "soft lock by
|
|
convention" to "hard lock by network topology." We're not protecting
|
|
against malicious services — that's an out-of-scope threat. We're
|
|
protecting against *accidental* corruption from honest mistakes in code
|
|
we don't fully control.
|
|
|
|
## The architecture in one picture
|
|
|
|
```
|
|
meta-core (only Redis writer + reader)
|
|
┌──────────────────────────────────────┐
|
|
│ HTTP API surface: │
|
|
│ POST/PUT/PATCH /api/metadata/* │ ◄── writes
|
|
│ GET /api/meta/{cid} │ ◄── reads (by CID)
|
|
│ GET /api/metadata/* │ ◄── reads (by id)
|
|
│ GET /api/file/{cid} │ ◄── content
|
|
│ GET /api/events/files │ ◄── SSE stream
|
|
│ GET /api/events/meta │ ◄── SSE stream
|
|
│ │ │
|
|
│ ▼ │
|
|
│ Redis (file:*, cid:*, streams) │
|
|
│ ◄── container-local, no exposed │
|
|
│ port, no docker-network alias │
|
|
└──────────────────────────────────────┘
|
|
▲ ▲
|
|
│ HTTP │ HTTP SSE
|
|
│ │
|
|
┌───────────┴────┐ ┌──────┴─────────┐
|
|
│ meta-sort │ │ meta-fuse │
|
|
│ meta-stremio │ │ meta-share │
|
|
│ meta-dup │ │ (any future) │
|
|
└────────────────┘ └────────────────┘
|
|
```
|
|
|
|
**The single-writer rule.** meta-core has exclusive write authority over
|
|
the `file:*`, `cid:*`, `file:__index__`, and `meta-core:*` keyspaces. No
|
|
other service can write — not because of an honoured convention, but
|
|
because they can't reach Redis at all.
|
|
|
|
**Stream reads also mediated.** Events go out through SSE endpoints on
|
|
meta-core's HTTP surface. Consumers don't see the underlying Redis
|
|
Streams. This is the Kubernetes / Consul pattern (see "Industry context"
|
|
below). It costs an extra HTTP hop versus the current direct XREAD; the
|
|
benefit is that the storage layer is free to evolve (or be replaced)
|
|
without breaking every consumer.
|
|
|
|
**Redis is private.** No host port mapping, no docker-network alias, no
|
|
URL in `kv-leader.info`. Discovery returns only meta-core's HTTP base.
|
|
|
|
## Eventing — HTTP Server-Sent Events
|
|
|
|
SSE is the chosen transport for cross-service event consumption. The
|
|
mapping from Redis Streams onto SSE is direct enough that no eventing
|
|
feature we currently use is lost.
|
|
|
|
### Feature mapping (what we keep, how we keep it)
|
|
|
|
| Redis Streams | SSE equivalent |
|
|
|---|---|
|
|
| Fan-out to N consumers | meta-core holds N SSE connections, broadcasts events to each |
|
|
| Replay from a cursor | `Last-Event-ID` header on reconnect — built into the SSE spec |
|
|
| Retention / MAXLEN | Underlying Redis Stream still does the trimming; SSE is a thin shim |
|
|
| Backpressure on slow consumers | meta-core's SSE handler blocks on the TCP write; events stay in Redis until trimmed |
|
|
| Multiple streams (file:events, meta:events) | Separate endpoints: `/api/events/files`, `/api/events/meta` |
|
|
| At-least-once via XACK | Implicit — client persists last-received `id`, sends it on reconnect (see "Cursor semantics and consumer-group migration" below) |
|
|
|
|
The one Streams feature we don't get is **consumer groups for
|
|
horizontally-scaled replicas of the same logical consumer.** We don't use
|
|
this today and don't plan to — meta-sort, meta-fuse, etc. each run one
|
|
instance. If horizontal scale ever happens, it'll be by sharding hash-IDs
|
|
across replicas, not by splitting an event stream. SSE stays adequate.
|
|
|
|
### Cursor semantics and consumer-group migration
|
|
|
|
Today three consumers use `XREADGROUP` against named consumer groups, not
|
|
bare `XREAD`:
|
|
|
|
- meta-sort: group `meta-sort-processor`
|
|
(`packages/meta-sort/.../events/FileEventConsumer.ts:23,66-74`)
|
|
- meta-dup: group on `file:events`
|
|
(`packages/meta-dup/.../MetaEventConsumer.ts:102-104`)
|
|
- meta-fuse: dual path — `XREADGROUP` on one consumer, plain `XREAD` on
|
|
another (`packages/meta-fuse/.../kv/RedisClient.ts:826,907`)
|
|
|
|
`XREADGROUP` keeps server-side state per group: a Pending Entries List
|
|
(PEL) and a `last-delivered-id` cursor. The load-bearing use today is
|
|
meta-sort's `processPendingEntries(idleMs=30000)`, which exists to claim
|
|
work from a crashed consumer's PEL on restart.
|
|
|
|
SSE has no equivalent server-side state. After the migration the contract
|
|
shifts to: **each consumer persists its own `Last-Event-ID` and resumes
|
|
from it on reconnect.** Implications:
|
|
|
|
- PEL-based crash recovery is gone. A crashed consumer that hadn't
|
|
persisted its cursor since entry X resumes from X (its last *durably
|
|
persisted* cursor), not from "whatever was in flight." Acceptable for
|
|
every current consumer — none run multiple instances per group — but
|
|
call out in the PR C migration of meta-sort that the recovery primitive
|
|
is changing.
|
|
- Each consumer must own its cursor persistence. Cheapest place is the
|
|
consumer's own state (a file, a Redis key written under its own
|
|
prefix, an in-memory variable if the consumer is OK with at-most-once
|
|
on its own crash).
|
|
- No server-side "ACK" call. The cursor *is* the ack — when the consumer
|
|
successfully writes Last-Event-ID = N to its own state, entries ≤ N
|
|
are considered handled. The SSE handler does nothing.
|
|
|
|
### Endpoint spec
|
|
|
|
```
|
|
GET /api/events/files
|
|
Request headers:
|
|
Last-Event-ID: <stream-entry-id> (optional; resume from this cursor)
|
|
Accept: text/event-stream
|
|
Response:
|
|
Content-Type: text/event-stream
|
|
Cache-Control: no-cache
|
|
Connection: keep-alive
|
|
X-Accel-Buffering: no (disable nginx response buffering)
|
|
```
|
|
|
|
Same shape for `/api/events/meta`.
|
|
|
|
### Event format
|
|
|
|
One SSE event per Redis Stream entry. Format:
|
|
|
|
```
|
|
id: 1747999999999-0
|
|
event: add
|
|
data: {"path":"/watch/foo.mkv","size":4500000000,"midhash256":"bafk…","timestamp":1747999999999}
|
|
|
|
id: 1748000000123-0
|
|
event: change
|
|
data: {"path":"/watch/foo.mkv","size":4500000000,"midhash256":"bafk…","timestamp":1748000000123}
|
|
```
|
|
|
|
- `id` — **opaque** cursor. Today it's the Redis Stream entry ID
|
|
(`<ms>-<seq>`); consumers MUST treat it as a black-box string they
|
|
echo back via `Last-Event-ID`. This contract lets meta-core swap the
|
|
backing store later without breaking every consumer's cursor handling.
|
|
- `event` — the `type` field from the underlying entry (`add` | `change`
|
|
| `delete` | `rename` | `reset` for `file:events`; `set` | `del` |
|
|
`expire` for `meta:events`).
|
|
- `data` — JSON-encoded rest of the entry. Keys match the existing stream
|
|
payload exactly so downstream consumers don't change their parser.
|
|
|
|
### Backing-store retention and lifecycle
|
|
|
|
**Contract: the SSE wire is a faithful mirror of the underlying Redis
|
|
Streams.** Consumers rely on `Last-Event-ID` resumption working across
|
|
*every* kind of meta-core hiccup, including a restart — that's the entire
|
|
point of having a cursor protocol. The backing store must therefore
|
|
preserve entries across meta-core process restart and bound its growth
|
|
explicitly, not implicitly.
|
|
|
|
Today's behaviour is wrong on both counts:
|
|
|
|
| Stream | Restart behaviour | Retention |
|
|
|---|---|---|
|
|
| `meta:events` | **Cleared on every MetaPublisher.Start** (`events/meta_publisher.go:50-58`) | No `MAXLEN` (unbounded) |
|
|
| `file:events` | Persists across restart | No `MAXLEN` (unbounded), cleared only on deliberate reset (`watcher/dispatcher.go:82-95`) |
|
|
|
|
The `meta:events` clear-on-restart is the load-bearing problem. With it
|
|
in place, every meta-core restart silently invalidates every consumer's
|
|
persisted cursor — the SSE `gap` event would fire on the first reconnect
|
|
after every restart, and the consumer would have to re-bootstrap. That
|
|
turns "restart meta-core to apply a config change" into "every downstream
|
|
service does a full re-sync," which is exactly the regression SSE is
|
|
supposed to prevent.
|
|
|
|
PR A must fix both streams:
|
|
|
|
1. **Remove the unconditional `Del(MetaEventsStream)` in
|
|
`MetaPublisher.Start`.** Entries persist across restart. The republish
|
|
path (`RepublishAllMetadata`) stays as a one-shot bootstrap for
|
|
missing entries, but it does not pre-truncate the stream.
|
|
2. **Add `MAXLEN ~ N` to every `XAdd` on both streams** so growth is
|
|
bounded. Approximate trim (`~`) is cheap. Default `N = 100_000` per
|
|
stream is fine for the foreseeable workload (a few KB per entry,
|
|
bounded total ≈ a few hundred MB) and easy to tune via config.
|
|
3. **Keep the deliberate `EmitReset → ClearStream` path on
|
|
`file:events`.** That clear is a feature: a reset is the consumer
|
|
contract for "throw out everything you knew, re-bootstrap." Consumers
|
|
detect it through the `event: reset` payload that's already emitted
|
|
as the first entry after the clear (`dispatcher.go:99-`). The SSE
|
|
`gap` event handles the race where a consumer's cursor was trimmed
|
|
*between* the clear and its reconnect.
|
|
|
|
After PR A, the cursor contract is **"durable across meta-core restart
|
|
and bounded by `MAXLEN ~ N`"** — matching what consumers already assume
|
|
when they persist a Last-Event-ID.
|
|
|
|
### Heartbeats
|
|
|
|
Server writes an SSE comment line (`:keep-alive\n\n`) every 30 seconds
|
|
when no events are flowing. Keeps reverse proxies and load balancers
|
|
from killing the connection as idle. The client ignores comment lines
|
|
per the SSE spec.
|
|
|
|
### Reconnect semantics
|
|
|
|
Per the SSE spec the client (or `EventSource`) auto-reconnects after a
|
|
network error, sending `Last-Event-ID`. The server resumes from the next
|
|
entry after that ID by translating to Redis Stream `XREAD … STREAMS file:events <id>`.
|
|
|
|
**Gap handling.** If the requested ID has been trimmed out of the Redis
|
|
Stream by retention, the server emits one synthetic event before resuming
|
|
from the oldest available entry:
|
|
|
|
```
|
|
event: gap
|
|
data: {"requested":"<id>","resumeFrom":"<oldest-id>","reason":"retention"}
|
|
```
|
|
|
|
Consumers that care about gap-free delivery (currently none — the
|
|
schema indexer tolerates gaps; ingest tasks rebuild from scratch) can
|
|
react. Consumers that don't care ignore it.
|
|
|
|
### Error handling
|
|
|
|
- **Redis unreachable on connect**: 503 with `Retry-After: 5`.
|
|
- **Redis fails mid-stream**: close the SSE connection. Client reconnects
|
|
with `Last-Event-ID`; the cycle resumes when Redis recovers.
|
|
- **Client disconnects**: handler goroutine exits cleanly; no per-client
|
|
state on meta-core.
|
|
|
|
### Auth
|
|
|
|
`/api/events/*` bypasses the hash-lock perimeter, same pattern as
|
|
`/api/meta/{cid}` and `/api/file/{cid}`. Event streams are internal
|
|
infrastructure and need to be reachable by peer services without going
|
|
through Authelia.
|
|
|
|
**Bypass ≠ public.** "Hash-lock bypass" means the OIDC sidecar doesn't
|
|
gate the route; it does *not* automatically mean Caddy proxies it from
|
|
the public hostname. The two existing precedents differ on this:
|
|
`/api/file/{cid}` is publicly reachable (peer-to-peer content fetch is
|
|
its job), `/api/meta/{cid}` likewise. For the SSE endpoints, the choice
|
|
is deliberate:
|
|
|
|
- If kept inside-only (no Caddy upstream for `/api/events/files` and
|
|
`/api/events/meta`), only sibling services on the docker network can
|
|
subscribe. This matches the rest of the "Redis is private" model and
|
|
is the recommended default.
|
|
- If exposed publicly, any browser can open an `EventSource` against
|
|
the stream without authentication — i.e. anyone can watch every file
|
|
event in the library. Only do this if there's a concrete consumer
|
|
that needs it and the visibility is acceptable.
|
|
|
|
PR A's Caddy change (see PR A scope, item 4) makes this an explicit
|
|
choice rather than a default behaviour. The recommendation is
|
|
inside-only until a concrete external consumer appears.
|
|
|
|
### Implementation footprint
|
|
|
|
The SSE handler itself is ~80 lines of Go in meta-core: one handler per
|
|
stream, each running an `XREAD … BLOCK 30s` loop and writing
|
|
SSE-formatted lines to the response. Go's `net/http` natively supports
|
|
streaming responses with `http.Flusher.Flush()`. Standard SSE clients
|
|
exist for every language we use: Node `eventsource`, Rust
|
|
`eventsource-client` (or `reqwest` + `tokio_util::codec`), Python
|
|
`sseclient`, Go `r3labs/sse`.
|
|
|
|
PR A as a whole is larger than the handler — see the PR A scope below
|
|
for the full list (backing-store fixes, Caddy wiring, poll deprecation)
|
|
that has to land alongside the handler for the cursor contract to hold.
|
|
|
|
## Eventing — considered alternatives
|
|
|
|
Recorded for posterity. None of these are being implemented; if any are
|
|
ever revisited it should be a deliberate reopen, not a drift.
|
|
|
|
- **Long-polling.** Strictly worse than SSE — connection churn on every
|
|
event, latency floor at the poll interval, no built-in resumption.
|
|
Rejected.
|
|
- **Webhooks (meta-core POSTs to subscribers).** Symmetric coupling:
|
|
every consumer would need to run an HTTP server reachable from
|
|
meta-core. Out-of-order delivery, retry, dead-letter all need
|
|
reinventing. Right for cross-organization integrations, wrong for
|
|
in-cluster eventing. Rejected.
|
|
- **External broker (Kafka / NATS / RabbitMQ).** Industrial-grade
|
|
pub/sub. New dependency, new operational surface, overkill for the
|
|
scale (single-host dev, ≤20-peer POC). Revisit if MetaMesh ever needs
|
|
cross-WAN federation at high throughput. Rejected for now.
|
|
- **Two Redises (writes Redis private, events Redis exposed).** Strongest
|
|
separation. Doubles operational complexity for no benefit over the
|
|
SSE design — once events go through HTTP, the events-Redis isn't
|
|
exposed either. Rejected.
|
|
- **Redis ACLs (Option B from earlier brainstorm).** Allows direct Redis
|
|
access from clients while restricting commands by user. Adds
|
|
credential plumbing and ACL config to operate; lets a buggy service
|
|
still connect, just with limited verbs. The SSE design closes the
|
|
network entirely, which is strictly stronger. Rejected — but the
|
|
equivalent escape hatch (open Redis to other services again) stays
|
|
available if we ever need it.
|
|
|
|
## Industry context — where this lands
|
|
|
|
The pattern we're adopting is the **Kubernetes / etcd** pattern, and
|
|
that's the most informative comparison.
|
|
|
|
Kubernetes is structurally identical to MetaMesh:
|
|
- A single source-of-truth store (etcd) holds all cluster state.
|
|
- A single service (kube-apiserver) is the sole writer to etcd.
|
|
- Many polyglot clients (kubectl, controllers, schedulers, kubelets,
|
|
operators) read and react to changes.
|
|
- Clients subscribe to *watch streams* for events.
|
|
|
|
What Kubernetes does, point-for-point:
|
|
- **etcd is never reached by clients.** It runs on a separate port,
|
|
often a separate network. kube-apiserver is the only thing that holds
|
|
etcd credentials. The lock is by network topology, not authentication.
|
|
- **Watch is also mediated.** kube-apiserver exposes `/watch` as an HTTP
|
|
streaming endpoint (very similar to SSE in shape). Clients don't watch
|
|
etcd directly — even though etcd has its own native watch primitive.
|
|
- **Storage portability falls out for free.** etcd could be swapped for
|
|
another store without any client change. We don't plan to swap Redis,
|
|
but the optionality is real and free.
|
|
|
|
Other systems with the same shape:
|
|
- **HashiCorp Consul.** Clients talk to a local agent over HTTP. The
|
|
agent talks to the cluster. KV writes and watches both flow through
|
|
the agent. Direct raft-store access is impossible.
|
|
- **Apache ZooKeeper.** Clients use the ZooKeeper client library which
|
|
speaks the ZooKeeper wire protocol; the data store is private.
|
|
- **Strapi / Sanity / Contentful** (headless CMS as analog). All
|
|
consumers — frontends, backend services, build pipelines — go through
|
|
the CMS HTTP API. Direct Postgres or content-store access is impossible
|
|
by design.
|
|
|
|
Where MetaMesh sat *before*: closer to the bad-monolith pattern
|
|
(everyone connects directly to the shared Redis). Where it sits *after*
|
|
this design: aligned with the canonical microservices pattern, and
|
|
specifically with the Kubernetes flavour where streams are mediated too.
|
|
|
|
### Advantages we're picking up
|
|
|
|
- **Schema evolution.** One service can change its data layout without
|
|
coordinating releases across every other service. The UUID-root
|
|
migration is the cautionary tale of what happens without this.
|
|
- **Validation choke point.** Every write passes through one code path
|
|
that can enforce invariants (reverse index, canonical_cid, type
|
|
constraints, schema version).
|
|
- **Observability.** Audit logs, request metrics, slow-query detection
|
|
all live at one layer.
|
|
- **Threat-model fit.** Untrusted-service-in-the-network is now in
|
|
scope; network isolation makes the concern moot.
|
|
- **Storage portability.** Optionality only, but real.
|
|
|
|
### Drawbacks (acknowledged)
|
|
|
|
- **Latency.** One extra hop per write (TCP + HTTP parse + handler).
|
|
Local-host overhead is sub-millisecond; mitigations are batched writes
|
|
(PATCH the whole metadata blob per plugin completion, not per field)
|
|
and Keep-Alive / HTTP/2 connections.
|
|
- **SSE-handler plumbing.** ~80 lines per stream endpoint plus per-client
|
|
goroutines. Trivial at our scale, real at Kubernetes scale (which is
|
|
why they invested in chunked watch with bookmarks, resource versions,
|
|
etc.). We can defer the same optimisations forever.
|
|
- **API surface size.** Every operation needs a corresponding endpoint.
|
|
Most of it already exists; one new SSE endpoint per stream is the
|
|
only new surface area introduced here.
|
|
|
|
## Enforcement — what "lock" actually means
|
|
|
|
Three layers, in order of effectiveness:
|
|
|
|
1. **Network isolation (hard).** Redis is on a container-private port.
|
|
No host port mapping. No docker-network alias. `kv-leader.info` does
|
|
not publish a Redis URL. Any service that wants to talk to Redis
|
|
needs container privileges, and at that point the lock isn't the
|
|
concern — operating-system isolation is. This is the actual lock.
|
|
|
|
2. **Documentation (convention).** This file, plus a one-line note in
|
|
`CLAUDE.md` and in each consumer service's `CLAUDE.md`: "All metadata
|
|
I/O routes through meta-core's HTTP API. Redis is private to
|
|
metacore-app." Engineers reading the codebase find the rule on first
|
|
contact. This catches the case of a future meta-core PR that
|
|
accidentally exposes Redis again.
|
|
|
|
3. **Code review (convention).** PRs that re-expose Redis (a port
|
|
mapping, an alias, a credential leak via leader-info) get rejected
|
|
with a pointer to this doc.
|
|
|
|
No runtime canary or lint is proposed — the network isolation makes
|
|
violations physically impossible from outside, and inside meta-core the
|
|
small file count makes review sufficient. Layer 1 does the heavy
|
|
lifting.
|
|
|
|
## Migration plan
|
|
|
|
Four PRs, additive, ordered so the system stays working throughout.
|
|
Closing the network access (PR D) is the moment the lock becomes a
|
|
lock; everything before it is opt-in migration that consumers can adopt
|
|
at their own pace.
|
|
|
|
### PR A — SSE endpoints in meta-core
|
|
|
|
Adds `GET /api/events/files` and `GET /api/events/meta`. Implements the
|
|
`Last-Event-ID` semantics, gap handling, heartbeats, and the per-stream
|
|
XREAD loop. Redis Streams stay reachable directly; consumers can
|
|
migrate one at a time.
|
|
|
|
Scope, in PR-order:
|
|
|
|
1. Backing-store fixes (see "Backing-store retention and lifecycle"
|
|
above) — remove the `meta:events` clear-on-restart, add `MAXLEN ~ N`
|
|
to every `XAdd`. These are prerequisites for the SSE cursor contract
|
|
to hold; without them PR A is not deliverable.
|
|
2. SSE handlers for the two streams.
|
|
3. Deprecate `GET /api/events/poll` (`watcher/handlers.go:29`). It
|
|
exists today as a long-poll variant over the same `file:events`
|
|
stream and is described in code as "always empty in practice"
|
|
(`watcher/handlers.go:55-58`). After SSE lands it's redundant — keep
|
|
it responding 200 for one release with a `Deprecation` header, then
|
|
remove. The `/api/scan/*` deprecation pattern in the same file is
|
|
the template.
|
|
4. Caddy perimeter wiring (see "Auth" subsection above) — explicit
|
|
allow-list for `/api/events/files` and `/api/events/meta` matching
|
|
whatever the perimeter policy decides. Either inside-only via the
|
|
docker network, or public via Caddy with deliberate authorisation.
|
|
Do not let the routes default-leak past the hash-lock.
|
|
|
|
Roughly 200 lines of Go + ~50 lines of test (covering reconnect from a
|
|
cursor, gap emission when the cursor is trimmed, heartbeat cadence,
|
|
durability across a meta-core restart).
|
|
|
|
### PR B — meta-sort writes via HTTP
|
|
|
|
Swap `RedisClient` for an `HttpKVClient` implementation of the existing
|
|
`IKVClient` interface in `packages/meta-sort-core`. All writes
|
|
(`setMetadataFlat`, `setMetadataProperty`, batch updates) become HTTP
|
|
calls against the **`/meta/{hash}` family** — the canonical write
|
|
surface for service-to-service traffic. Per-plugin write coalescing
|
|
uses `PATCH /meta/{hash}` (already calls `MergeMetadataFlat`
|
|
server-side with the partial-update semantics we need; see
|
|
`handlers.go:533-566`).
|
|
|
|
Reads from Redis stay direct in this PR (no consumer migration yet);
|
|
only writes move. Streams stay on direct XREAD.
|
|
|
|
**Pre-audit (already done):** meta-sort's production `kvClient.*`
|
|
surface is `setMetadataFlat` / `deleteMetadataFlat` / `getMetadataFlat`
|
|
/ `getMetadata` / `getAllHashIds` / `health` plus the `file:events`
|
|
stream consumer. No orphan keyspaces, no generic `kv.set(arbitrary_key)`
|
|
callers — the HTTP migration covers the entire write path with no
|
|
side bets.
|
|
|
|
### PR C — Stream consumers migrate to SSE
|
|
|
|
meta-sort's event consumer (`FileEventConsumer.ts`), meta-fuse's stream
|
|
reader, meta-stremio's event hook, meta-dup's monitor, meta-share's
|
|
ingest task — each swaps its `XREADGROUP` / `XREAD` loop for an SSE
|
|
client. Per-language standard libraries make this small (one HTTP
|
|
request, one event-handler closure).
|
|
|
|
In the same PR, each consumer's `LeaderClient` (TypeScript x2, Python,
|
|
plus any new Rust binding for meta-share if added) drops its
|
|
*requirement* on the `redisUrl` field — i.e. it stops opening a Redis
|
|
connection. It can still tolerate the field being present (PR D removes
|
|
it from the publisher side). This ordering matters: PR D's loud-failure
|
|
guard cannot ship until every consumer has been demonstrated to boot
|
|
without `redisUrl`.
|
|
|
|
The schema indexer is internal to meta-core and unaffected.
|
|
|
|
### PR D — Close Redis network access
|
|
|
|
PR D must land **after** every consumer in PR C has stopped requiring
|
|
`redisUrl` (verify by booting each service against a meta-core build
|
|
that already omits the field — staging environment or feature-flagged
|
|
release). Until that's true, this PR will deadlock the boot sequence.
|
|
|
|
Then, in this order:
|
|
|
|
1. Update consumer-side discovery (`LeaderClient` in each service) to
|
|
*log loudly* if it sees a `redisUrl` field — guards against
|
|
accidental rollback. (Loud log, not hard failure, while the field
|
|
is still being written — flip to hard failure once step 4 lands.)
|
|
2. Stop publishing `redisUrl` from `/urls` and from `kv-leader.info`.
|
|
3. Remove the `meta-core` docker-network alias on the metacore-app
|
|
service (other services no longer need DNS for the Redis port).
|
|
4. Remove `6380:6379` host port mapping in `dev/docker-compose.yml`.
|
|
5. Flip the LeaderClient guard from "warn" to "raise" — any future
|
|
consumer that tries to require the field fails loudly at boot.
|
|
|
|
After PR D, attempting to connect to Redis from anywhere except
|
|
metacore-app produces a connection refused. The lock is real.
|
|
|
|
### Out of scope for this work
|
|
|
|
- **Redis ACLs / proxy.** Network isolation makes these unnecessary.
|
|
Available as an escape hatch if the threat model ever changes.
|
|
- **Replacing Redis Streams under the hood.** SSE is the externalised
|
|
contract; the backing store is an implementation detail meta-core can
|
|
change later.
|
|
- **External brokers.** Out of scope absent a concrete driver
|
|
(cross-WAN federation at high throughput).
|
|
|
|
## Decisions recorded
|
|
|
|
| Question | Decision | Notes |
|
|
|---|---|---|
|
|
| Writes from non-meta-core services? | Forbidden by network topology | Redis unreachable; not a convention |
|
|
| Reads from `file:*` direct? | Forbidden by network topology | Same lock, same reason |
|
|
| Event streams direct? | No — mediated through SSE | Kubernetes-style. Costs one HTTP hop; buys schema evolution + threat-model fit |
|
|
| Enforcement mechanism? | Network isolation (primary) + docs + code review (secondary) | No ACLs, no proxy, no lint |
|
|
| Backing store for streams? | Redis Streams (unchanged) | SSE wraps it; could be swapped later without consumer impact |
|
|
| Consumer groups / horizontal scale? | Not supported via SSE | Not needed today; revisit if scale ever requires it |
|
|
| Storage portability? | Not a goal but a free side-effect | Will not be exercised in alpha |
|
|
| **Canonical write API path?** | **`/meta/{hash}` family** | Has GET/PUT/**PATCH**/DELETE plus `/_add/{key}` and per-property `{key:.*}` operations. `PATCH` calls `MergeMetadataFlat` — matches the partial-update semantics PR B needs. `/api/metadata/{hashId}` stays as the editor-internal surface (no PATCH, no per-property routes) but is not the migration target. |
|
|
| **SSE event-id contract?** | **Opaque to consumers** | Today it's the Redis Stream entry ID (`<ms>-<seq>`); consumers MUST treat as opaque so the backing store can be swapped without breaking the wire contract. Documented in the SSE endpoint comment and in this doc. |
|
|
| **Typed error envelope?** | **`{error, message, retryable}` with stable `error` slug** | See "Error envelope" subsection below. |
|
|
| **Plugin direct-Redis risk?** | **Confirmed: no plugin uses Redis directly** | Grep across `packages/plugins/` for `ioredis` / `redis.createClient` / `new Redis()` returned empty. Plugins POST results back to meta-sort via `/api/plugins/callback`; meta-sort then writes. PR B picks up the plugin write path transparently. |
|
|
| **meta-sort non-`file:*` writes?** | **Confirmed: none in production** | Full audit of `kvClient.*` callers in `packages/meta-sort/packages/meta-sort-core/src/` shows only: `setMetadataFlat`, `deleteMetadataFlat`, `getMetadataFlat`, `getMetadata`, `getAllHashIds`, `health`, plus the `file:events` stream consumer (`initStreamConsumer` / `startStreamConsumer` / `processPendingEntries`). The generic `kv.set(key, value)` / `kv.setProperty(key, value)` methods on the interface exist for plugin sandboxes and tests but are not invoked by production code with non-`file:*` keys. PR B + PR C cover the full meta-sort surface. |
|
|
|
|
## Error envelope
|
|
|
|
Standard shape for all 4xx / 5xx responses from the metadata API:
|
|
|
|
```json
|
|
{
|
|
"error": "alias_collision",
|
|
"message": "CID midhash256:bafk… already resolves to UUID 01JKR…",
|
|
"retryable": false
|
|
}
|
|
```
|
|
|
|
Stable `error` slugs the client should switch on:
|
|
|
|
| Slug | HTTP status | Retryable | Meaning |
|
|
|---|---|---|---|
|
|
| `alias_collision` | 409 | no | A different UUID already owns the CID the caller is trying to register. Caller bug; escalate. |
|
|
| `unknown_root` | 404 | no | The hashId / UUID doesn't exist. Caller probably has a stale reference. |
|
|
| `unknown_cid` | 404 | no | The CID isn't in the reverse index. No retry helps. |
|
|
| `schema_violation` | 400 | no | Body shape rejected (missing required field, invalid value). Caller bug. |
|
|
| `storage_unavailable` | 503 | yes | Redis transient. Caller should retry with backoff. |
|
|
| `internal` | 500 | yes (cautiously) | Unhandled server-side error. Retry once; escalate if persistent. |
|
|
|
|
Today's handlers return `{error, message}` strings; PR A is the natural
|
|
place to introduce the typed shape (along with the new SSE endpoints
|
|
which will need their own error responses anyway). Going through a
|
|
typed envelope from the start avoids the retrofit cost.
|
|
|
|
## Remaining open questions
|
|
|
|
The audit closed everything that was answerable. What's left:
|
|
|
|
- **Deprecation timeline for `/api/metadata/{hashId}`.** It stays during
|
|
the migration (editor uses it), but once we've consolidated on
|
|
`/meta/{hash}` for service-to-service writes the editor could migrate
|
|
too and we'd delete the duplicate surface. Not urgent; revisit
|
|
post-PR D when everything else is stable.
|
|
|
|
- **What does `/api/events/files` emit during the watcher's initial
|
|
bootstrap scan?** When meta-core starts on a populated library, the
|
|
watcher discovers N files in quick succession and publishes N `add`
|
|
events. A consumer subscribing for the first time will receive that
|
|
whole flood. Probably fine — consumers are designed to handle bursts
|
|
— but worth a smoke test once PR A lands to confirm meta-share's
|
|
ingest doesn't trip on the cardinality.
|
|
|
|
## Non-goals
|
|
|
|
- **Authentication between internal services.** The hash-lock OIDC
|
|
perimeter handles external clients; internal service-to-service
|
|
trust is by network isolation (docker network for dev, equivalent
|
|
isolation in prod deployments). Adding service-to-service auth is a
|
|
separate concern.
|
|
- **Replacing the public HTTP API surface.** This proposal exercises
|
|
what already exists; the only new endpoints are the two SSE streams.
|
|
- **Introducing new transport, broker, proxy, or sidecar.** Everything
|
|
builds on what's deployed today.
|
|
- **Migrating meta-fuse / meta-stremio / meta-dup direct Redis reads
|
|
via XSCAN/GET** (separate from event-stream reads). These are
|
|
read-only and don't break schema invariants, but they will fail once
|
|
PR D closes Redis access. PR C is the migration window for them —
|
|
the same SSE adoption work covers it.
|