Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84af298792 | ||
|
|
b0adefb97b | ||
|
|
5369dc4b9c | ||
|
|
fb4bdad61b | ||
|
|
dc5cbdfb9b | ||
|
|
c53146e514 | ||
|
|
bb04a3f4dc | ||
|
|
36eb3543b9 | ||
|
|
155fd83392 | ||
|
|
cf63a8da68 |
@@ -0,0 +1,90 @@
|
|||||||
|
# Gitea twin of .github/workflows/docker-publish.yml. Gitea runs only .gitea/workflows/ once this
|
||||||
|
# folder exists, and GitHub never reads it.
|
||||||
|
#
|
||||||
|
# Publishes to IPCR on watch.nsl.sh instead of ghcr.io:
|
||||||
|
# push ipcr-push-85-17-246-67.nip.io/metadec.eth/<repo>:<tag> docker login as metadec.eth with the
|
||||||
|
# IPCR_PUSH_TOKEN secret (the metadec.eth namespace token from the IPCR dashboard)
|
||||||
|
# pull ipcr-watch.nsl.sh/ipns/metadec.eth/metadec.eth/<repo>:<tag> anonymous
|
||||||
|
# The push goes to ipcr-push-85-17-246-67.nip.io, the box's own address, not ipcr-push-watch.nsl.sh: Cloudflare fronts
|
||||||
|
# *.watch.nsl.sh and refuses any request over 100 MB (413), which a large image layer is.
|
||||||
|
# That host's certificate is signed by the box's private Mesh Router CA, given to buildkit from
|
||||||
|
# the IPCR_CA_PEM repo variable. docker login would make the job's Docker daemon check the
|
||||||
|
# certificate without that CA, so the login is written straight into ~/.docker/config.json.
|
||||||
|
# The runner's job image (node:22-bookworm) has no docker CLI, so the static one is
|
||||||
|
# installed first; buildx installs itself. No `type=gha` cache: the runner has none.
|
||||||
|
|
||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag for the image (optional, defaults to sha)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ipcr-push-85-17-246-67.nip.io
|
||||||
|
IMAGE_NAME: metadec.eth/${{ github.event.repository.name }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Docker CLI
|
||||||
|
run: curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-28.5.1.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
|
||||||
|
|
||||||
|
- name: Trust the Mesh Router CA
|
||||||
|
run: printf '%s\n' "$IPCR_CA_PEM" > /tmp/ipcr-ca.pem
|
||||||
|
env:
|
||||||
|
IPCR_CA_PEM: ${{ vars.IPCR_CA_PEM }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
buildkitd-config-inline: |
|
||||||
|
[registry."${{ env.REGISTRY }}"]
|
||||||
|
ca = ["/tmp/ipcr-ca.pem"]
|
||||||
|
|
||||||
|
- name: Log in to IPCR
|
||||||
|
env:
|
||||||
|
IPCR_PUSH_TOKEN: ${{ secrets.IPCR_PUSH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.docker
|
||||||
|
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$REGISTRY" "$(printf 'metadec.eth:%s' "$IPCR_PUSH_TOKEN" | base64 -w0)" > ~/.docker/config.json
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
# A fix to release 1.2.3 is tagged v1.2.3.1: four numbers are not semver, so
|
||||||
|
# the pattern above makes no tag for it; this one does (image tag 1.2.3.1).
|
||||||
|
type=match,pattern=v(\d+\.\d+\.\d+\.\d+),group=1
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=sha,prefix=
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -45,6 +45,9 @@ jobs:
|
|||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
# A fix to release 1.2.3 is tagged v1.2.3.1: four numbers are not semver, so
|
||||||
|
# the pattern above makes no tag for it; this one does (image tag 1.2.3.1).
|
||||||
|
type=match,pattern=v(\d+\.\d+\.\d+\.\d+),group=1
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=sha,prefix=
|
type=sha,prefix=
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|||||||
+10
-9
@@ -6,20 +6,21 @@
|
|||||||
# - Bash-based leader election script (flock on shared volume)
|
# - Bash-based leader election script (flock on shared volume)
|
||||||
# - meta-core Go binary (HTTP API, Mount API, Watcher)
|
# - meta-core Go binary (HTTP API, Mount API, Watcher)
|
||||||
# - nginx (dashboard, WebDAV, API proxy)
|
# - nginx (dashboard, WebDAV, API proxy)
|
||||||
# - Redis (managed by supervisord on leader)
|
# - SQLite metadata store, embedded in the Go binary: truth /meta-core/db/meta-core.db
|
||||||
|
# (signed claims, backed up) + cache /meta-core/db-cache/cache.db (rebuildable)
|
||||||
# - rclone (single mount executor — SMB goes through rclone's :smb: backend)
|
# - rclone (single mount executor — SMB goes through rclone's :smb: backend)
|
||||||
# - supervisord (process management - runs on leader only)
|
# - supervisord (process management - runs on leader only)
|
||||||
#
|
#
|
||||||
# Architecture:
|
# Architecture:
|
||||||
# - leader-election.sh handles flock, starts supervisord on leader
|
# - leader-election.sh handles flock, starts supervisord on leader
|
||||||
# - Followers remain dormant, retry flock every 5s
|
# - Followers remain dormant, retry flock every 5s
|
||||||
# - Only leader runs Redis, meta-core, nginx, rclone, mount-watcher
|
# - Only leader runs meta-core, nginx, rclone, mount-watcher
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Stage 1: Build Go binary
|
# Stage 1: Build Go binary
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
FROM golang:1.21-alpine AS go-builder
|
FROM golang:1.26-alpine AS go-builder
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ ARG RCLONE_VERSION=1.73.5
|
|||||||
# Install runtime dependencies. Native NFS/CIFS clients intentionally absent —
|
# Install runtime dependencies. Native NFS/CIFS clients intentionally absent —
|
||||||
# all remote mounts go through rclone now.
|
# all remote mounts go through rclone now.
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
redis \
|
sqlite \
|
||||||
nginx \
|
nginx \
|
||||||
nginx-mod-http-dav-ext \
|
nginx-mod-http-dav-ext \
|
||||||
supervisor \
|
supervisor \
|
||||||
@@ -104,7 +105,8 @@ RUN chmod +x /usr/local/bin/meta-core
|
|||||||
# Create directories
|
# Create directories
|
||||||
RUN mkdir -p \
|
RUN mkdir -p \
|
||||||
/meta-core/locks \
|
/meta-core/locks \
|
||||||
/meta-core/db/redis \
|
/meta-core/db \
|
||||||
|
/meta-core/db-cache \
|
||||||
/meta-core/services \
|
/meta-core/services \
|
||||||
/meta-core/mounts/errors \
|
/meta-core/mounts/errors \
|
||||||
/files \
|
/files \
|
||||||
@@ -140,13 +142,12 @@ ENV META_CORE_PATH=/meta-core \
|
|||||||
SERVICE_VERSION=1.0.0 \
|
SERVICE_VERSION=1.0.0 \
|
||||||
META_CORE_HTTP_PORT=9000 \
|
META_CORE_HTTP_PORT=9000 \
|
||||||
META_CORE_HTTP_HOST=0.0.0.0 \
|
META_CORE_HTTP_HOST=0.0.0.0 \
|
||||||
REDIS_PORT=6379 \
|
|
||||||
WATCH_INTERVAL_MS=1000
|
WATCH_INTERVAL_MS=1000
|
||||||
|
|
||||||
# Expose ports
|
# Expose ports
|
||||||
# 80: nginx (dashboard + API proxy + WebDAV)
|
# 80: nginx (dashboard + API proxy + WebDAV). The metadata store has no port:
|
||||||
# 6379: Redis (for other services)
|
# it is a file inside this container, reachable only through the HTTP API.
|
||||||
EXPOSE 80 6379
|
EXPOSE 80
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Variables
|
# Variables
|
||||||
BINARY := meta-core
|
BINARY := meta-core
|
||||||
VERSION ?= 1.0.0
|
VERSION ?= 2.0.0
|
||||||
BUILD_DIR := bin
|
BUILD_DIR := bin
|
||||||
DOCKER_IMAGE := meta-core
|
DOCKER_IMAGE := meta-core
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# meta-core
|
# meta-core
|
||||||
|
|
||||||
Standalone Go service that owns Redis, the HTTP+SSE metadata surface, the
|
Standalone Go service that owns the metadata store (an embedded SQLite file),
|
||||||
|
the HTTP+SSE metadata surface, the
|
||||||
User Data Layer store and signing keystore, and a WebDAV file server for the
|
User Data Layer store and signing keystore, and a WebDAV file server for the
|
||||||
MetaMesh dev/prod stack.
|
MetaMesh dev/prod stack.
|
||||||
|
|
||||||
@@ -10,19 +11,19 @@ meta-core runs as its own container (`metacore-app`). Other MetaMesh services
|
|||||||
(meta-sort, meta-fuse, meta-stremio, meta-dup, meta-search, meta-share,
|
(meta-sort, meta-fuse, meta-stremio, meta-dup, meta-search, meta-share,
|
||||||
meta-gateway, meta-watch, …) locate it over UDP multicast (beacon v2 —
|
meta-gateway, meta-watch, …) locate it over UDP multicast (beacon v2 —
|
||||||
its `metamesh.core` advertise carries the full `/urls` payload) and then call its
|
its `metamesh.core` advertise carries the full `/urls` payload) and then call its
|
||||||
HTTP API. `META_CORE_URL` on a client pins it instead. Redis is not published
|
HTTP API. `META_CORE_URL` on a client pins it instead. The store is embedded
|
||||||
outside the container — all metadata I/O is mediated by the HTTP/SSE API
|
in the Go binary (no database port at all) — all metadata I/O is mediated by the HTTP/SSE API
|
||||||
(see [`docs/api-mediated-access.md`](docs/api-mediated-access.md)).
|
(see [`docs/api-mediated-access.md`](docs/api-mediated-access.md)).
|
||||||
|
|
||||||
### Key capabilities
|
### Key capabilities
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| **Redis-volume mutex** | Bash `flock` loop in `docker/leader-election.sh` only `exec`s supervisord on the winner, so two containers sharing one `/meta-core` volume can never run two Redis writers. It is a mutex, not discovery — nothing reads the lock. |
|
| **DB-file mutex** | Bash `flock` loop in `docker/leader-election.sh` only `exec`s supervisord on the winner, so two containers sharing one `/meta-core` volume can never run two writers on the database file. It is a mutex, not discovery — nothing reads the lock. |
|
||||||
| **Redis owner** | Supervisord starts Redis with AOF + RDB persistence inside the container; meta-core connects to it on localhost. |
|
| **Embedded SQLite store** | One schema-free `kv(k, v)` table in `/meta-core/db/meta-core.db` (pure-Go `modernc.org/sqlite`, WAL, `synchronous=NORMAL`). One writer goroutine with group commit; a record write (fields + root index + CID aliases + FTS row + events) is atomic. Replaced Redis in 2026-10 (Redis + the in-memory search copy needed ~6.6 GB on watch.nsl.sh's 169k records; the SQLite file is ~2.5 GB on disk with a bounded page cache). |
|
||||||
| **HTTP API** | Typed REST surface (gorilla/mux) for metadata, KV browsing, snapshots, schema inference, mounts, watchers, neighbours, identity, UDL, and file access by CID. |
|
| **HTTP API** | Typed REST surface (gorilla/mux) for metadata, KV browsing, snapshots, schema inference, mounts, watchers, neighbours, identity, UDL, and file access by CID. |
|
||||||
| **In-memory search index** | `POST /api/metadata/search` matches against an in-memory snapshot of every record, kept current by incremental refreshes (full reconcile every 30 min) — no Redis round-trip per record on the request path (`internal/api/search_index.go`). |
|
| **Indexed search** | `POST /api/metadata/search` builds a superset of candidates in SQL — a partial expression index `kv_field_v` over (field name, value) of every record key, plus an FTS5 trigram table; the smallest part drives, the other filters are probed per candidate — then verifies each with the unchanged Go matchers. Fresh immediately, no in-memory copy (`internal/storage/search.go`, `internal/api/search_index.go`). |
|
||||||
| **SSE event streams** | `/api/events/files` and `/api/events/meta` mirror the underlying Redis Streams so external services never speak Redis. |
|
| **SSE event streams** | `/api/events/files` and `/api/events/meta` mirror the store's `file:events` / `meta:events` streams so external services never touch the store. `meta:events` entries are written in the same transaction as the change. |
|
||||||
| **WebDAV server** | `/webdav/*` exposes the `/files` volume (read+write) for cross-service file access. nginx in front of meta-core handles caching. |
|
| **WebDAV server** | `/webdav/*` exposes the `/files` volume (read+write) for cross-service file access. nginx in front of meta-core handles caching. |
|
||||||
| **Service discovery** | beacon v2: UDP multicast advertise/listen on `239.255.99.1:9099`, neighbours served at `/neighbors`. The file-based registry under `/meta-core/services/*.json` it replaced is gone. |
|
| **Service discovery** | beacon v2: UDP multicast advertise/listen on `239.255.99.1:9099`, neighbours served at `/neighbors`. The file-based registry under `/meta-core/services/*.json` it replaced is gone. |
|
||||||
| **File watcher / scanner** | Recursive scan + MidHash256 computation; emits events on `file:events`. Watched roots are POSTed watcher configs (`/meta-core/watchers.json`), not env vars. |
|
| **File watcher / scanner** | Recursive scan + MidHash256 computation; emits events on `file:events`. Watched roots are POSTed watcher configs (`/meta-core/watchers.json`), not env vars. |
|
||||||
@@ -35,13 +36,15 @@ outside the container — all metadata I/O is mediated by the HTTP/SSE API
|
|||||||
### Design characteristics
|
### Design characteristics
|
||||||
|
|
||||||
- **Single writer** — flock on the `/meta-core` volume; no external consensus.
|
- **Single writer** — flock on the `/meta-core` volume; no external consensus.
|
||||||
- **HTTP-only externally** — Redis is never published by the compose stacks;
|
- **HTTP-only externally** — there is no database port; debug read-only with
|
||||||
debug with `docker exec metacore-app redis-cli …`.
|
`docker exec metacore-app sqlite3 -readonly /meta-core/db/meta-core.db …`
|
||||||
|
(dev stacks: `/meta-core-db/meta-core.db`).
|
||||||
- **Schema-gated boot** — `EnsureSchemaVersion` aborts startup when the
|
- **Schema-gated boot** — `EnsureSchemaVersion` aborts startup when the
|
||||||
on-disk Redis layout predates the current build (alpha clean-wipe policy;
|
on-disk store layout predates the current build (alpha clean-wipe policy;
|
||||||
see `docs/uuid-rooted-metadata.md`).
|
see `docs/uuid-rooted-metadata.md`).
|
||||||
- **Static binary** — CGO disabled, single Go binary plus a thin Alpine
|
- **Static binary** — CGO disabled (pure-Go SQLite), single Go binary plus a
|
||||||
image with `redis-server`, `rclone`, nginx, and supervisord.
|
thin Alpine image with `rclone`, nginx, supervisord and the `sqlite` CLI
|
||||||
|
(for debugging).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -52,19 +55,19 @@ outside the container — all metadata I/O is mediated by the HTTP/SSE API
|
|||||||
│ leader-election.sh (flock /meta-core/locks/kv-leader.lock, │
|
│ leader-election.sh (flock /meta-core/locks/kv-leader.lock, │
|
||||||
│ then exec supervisord) │
|
│ then exec supervisord) │
|
||||||
│ supervisord │
|
│ supervisord │
|
||||||
│ ├─ redis-server (AOF + RDB on /meta-core/db/redis) │
|
|
||||||
│ ├─ nginx :80 (dashboard, /editor, API proxy, │
|
│ ├─ nginx :80 (dashboard, /editor, API proxy, │
|
||||||
│ │ proxy_cache for /webdav, rclone GUI /rclone) │
|
│ │ proxy_cache for /webdav, rclone GUI /rclone) │
|
||||||
│ ├─ rclone rcd (mount daemon, RC API on :5572) │
|
│ ├─ rclone rcd (mount daemon, RC API on :5572) │
|
||||||
│ ├─ mount-watcher.sh (rclone mount lifecycle) │
|
│ ├─ mount-watcher.sh (rclone mount lifecycle) │
|
||||||
│ └─ meta-core (Go) ─► HTTP API + SSE + WebDAV on :9000 │
|
│ └─ meta-core (Go) ─► HTTP API + SSE + WebDAV on :9000 │
|
||||||
|
│ ─► embedded SQLite (db/meta-core.db) │
|
||||||
│ ─► UDP advertise/listen 239.255.99.1:9099 │
|
│ ─► UDP advertise/listen 239.255.99.1:9099 │
|
||||||
└──────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌──────────────────────┴──────────────────────┐
|
┌──────────────────────┴──────────────────────┐
|
||||||
│ /meta-core (meta-core's own volume) │
|
│ /meta-core (meta-core's own volume) │
|
||||||
│ locks/kv-leader.lock │
|
│ locks/kv-leader.lock │
|
||||||
│ db/redis/ │
|
│ db/meta-core.db (+ -wal, -shm) │
|
||||||
│ identity/accounts/*.json │
|
│ identity/accounts/*.json │
|
||||||
│ mounts/{mounts.json, errors/} │
|
│ mounts/{mounts.json, errors/} │
|
||||||
│ watchers.json │
|
│ watchers.json │
|
||||||
@@ -73,7 +76,7 @@ outside the container — all metadata I/O is mediated by the HTTP/SSE API
|
|||||||
|
|
||||||
meta-sort / meta-fuse / meta-stremio / meta-search / meta-share / …
|
meta-sort / meta-fuse / meta-stremio / meta-search / meta-share / …
|
||||||
│ 1. hear the metamesh.core advertise on UDP multicast (carries /urls)
|
│ 1. hear the metamesh.core advertise on UDP multicast (carries /urls)
|
||||||
└─ 2. HTTP + SSE (+ WebDAV) to metacore-app — never Redis, never the volume
|
└─ 2. HTTP + SSE (+ WebDAV) to metacore-app — never the store, never the volume
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
@@ -138,28 +141,32 @@ Environment variables (from `internal/config/config.go` unless noted):
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `META_CORE_PATH` | `/meta-core` | meta-core's own volume root (lock, Redis data, identity, mounts, watchers, cache). No other service mounts it. |
|
| `META_CORE_PATH` | `/meta-core` | meta-core's own volume root (lock, SQLite DB, identity, mounts, watchers, cache). No other service mounts it. |
|
||||||
| `FILES_PATH` | `/files` | Files volume root. |
|
| `FILES_PATH` | `/files` | Files volume root. |
|
||||||
| `SERVICE_NAME` | `meta-core` | Name in the beacon v2 advertise. |
|
| `SERVICE_NAME` | `meta-core` | Name in the beacon v2 advertise. |
|
||||||
| `SERVICE_VERSION` | `1.0.0` | Reported via `/status`. |
|
| `SERVICE_VERSION` | `1.0.0` | Reported via `/status`. |
|
||||||
| `API_PORT` | `8180` | External port baked into the constructed `baseUrl` when `BASE_URL` is unset. |
|
| `API_PORT` | `8180` | External port baked into the constructed `baseUrl` when `BASE_URL` is unset. |
|
||||||
| `BASE_URL` | _empty_ | Overrides the constructed `baseUrl` (the Caddy/HTTPS perimeter URL). |
|
| `BASE_URL` | _empty_ | Overrides the constructed `baseUrl` (the Caddy/HTTPS perimeter URL). |
|
||||||
| `META_CORE_PUBLIC_URL` | _empty_ | Browser-facing nav URL in the announce; wins over `BASE_URL` (e.g. a debug-direct port with no Caddy). |
|
| `META_CORE_PUBLIC_URL` | _empty_ | Browser-facing nav URL in the announce; wins over `BASE_URL` (e.g. a debug-direct port with no Caddy). |
|
||||||
| `REDIS_PORT` | `6379` | Local Redis port. |
|
| `META_CORE_DB_PATH` | `<META_CORE_PATH>/db/meta-core.db` | SQLite database file. Dev stacks set `/meta-core-db/meta-core.db` on a named volume (the `/meta-core` bind is 9p on Windows dev boxes and SQLite WAL is unsafe there). |
|
||||||
|
| `META_CORE_DB_CACHE_MB` | `64` | Page cache of the writer connection (`internal/storage/client.go`). |
|
||||||
|
| `META_CORE_DB_READER_CACHE_MB` | `16` | Page cache per reader connection. |
|
||||||
|
| `META_CORE_DB_READERS` | `8` | Reader connection pool size. |
|
||||||
| `META_CORE_HTTP_PORT` | `9000` | Go HTTP+SSE+WebDAV port. |
|
| `META_CORE_HTTP_PORT` | `9000` | Go HTTP+SSE+WebDAV port. |
|
||||||
| `META_CORE_HTTP_HOST` | `0.0.0.0` | HTTP bind. |
|
| `META_CORE_HTTP_HOST` | `0.0.0.0` | HTTP bind. |
|
||||||
| `ENABLE_FILE_WATCHER` | `true` | Disable to suppress the in-process watcher entirely (`POST /api/files/register` stays available). |
|
| `ENABLE_FILE_WATCHER` | `true` | Disable to suppress the in-process watcher entirely (`POST /api/files/register` stays available). |
|
||||||
| `ENABLE_UDP_DISCOVERY` | `true` | beacon v2 advertise + listen. |
|
| `ENABLE_UDP_DISCOVERY` | `true` | beacon v2 advertise + listen. |
|
||||||
| `BEACON_GROUP` / `BEACON_PORT` | `239.255.99.1` / `9099` | Multicast group and UDP port. |
|
| `BEACON_GROUP` / `BEACON_PORT` | `239.255.99.1` / `9099` | Multicast group and UDP port. |
|
||||||
| `BEACON_INTERVAL_MS` | `10000` | Unsolicited advertise interval. |
|
| `BEACON_INTERVAL_MS` | `10000` | Unsolicited advertise interval. |
|
||||||
| `META_CORE_INDEX_EXCLUDE_PREFIXES` | `categories/newznab/` | Comma-separated field prefixes left out of the in-memory search index; set empty to index everything (`internal/storage/client.go`). |
|
|
||||||
| `ELECTION_RETRY_SECS` | `5` | flock retry interval (read by `docker/leader-election.sh`, not the Go binary). |
|
| `ELECTION_RETRY_SECS` | `5` | flock retry interval (read by `docker/leader-election.sh`, not the Go binary). |
|
||||||
|
|
||||||
`HEALTH_CHECK_INTERVAL_MS`, `HEARTBEAT_INTERVAL_MS`, `STALE_THRESHOLD_MS`,
|
`HEALTH_CHECK_INTERVAL_MS`, `HEARTBEAT_INTERVAL_MS`, `STALE_THRESHOLD_MS`,
|
||||||
`CLEANUP_INTERVAL_MS`, `DEAD_SERVICE_THRESHOLD_MS`, `WATCH_INTERVAL_MS` and
|
`CLEANUP_INTERVAL_MS`, `DEAD_SERVICE_THRESHOLD_MS`, `WATCH_INTERVAL_MS` and
|
||||||
`DEBOUNCE_MS` are still parsed by `internal/config/config.go` but nothing reads
|
`DEBOUNCE_MS` are still parsed by `internal/config/config.go` but nothing reads
|
||||||
them (leftovers of the file-based registry); setting them has no effect. There
|
them (leftovers of the file-based registry); setting them has no effect. There
|
||||||
is no `META_CORE_REDIS_PORT` / `META_CORE_SERVICE_NAME` /
|
is no `REDIS_PORT` / `META_CORE_REDIS_PORT` / `META_CORE_INDEX_EXCLUDE_PREFIXES`
|
||||||
|
(gone with Redis and the in-memory search index; search results now include
|
||||||
|
`categories/newznab/*` like every record read) / `META_CORE_SERVICE_NAME` /
|
||||||
`META_CORE_LEADER_TIMEOUT`, and no `WATCH_PATHS` — watched folders are watcher
|
`META_CORE_LEADER_TIMEOUT`, and no `WATCH_PATHS` — watched folders are watcher
|
||||||
configs created via `/api/watchers`.
|
configs created via `/api/watchers`.
|
||||||
|
|
||||||
@@ -174,10 +181,10 @@ the in-container Go port `:9000`; from the host use
|
|||||||
### Health, leader, URLs
|
### Health, leader, URLs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GET /health # storage + role
|
GET /health # storage + role (`redis: bool` kept for compatibility = store open)
|
||||||
GET /status # version, uptime, etc.
|
GET /status # version, uptime, etc.
|
||||||
GET /leader # current leader info (hostname, PID, timestamps)
|
GET /leader # current leader info (hostname, PID, timestamps)
|
||||||
GET /urls # baseUrl, apiUrl, webdavUrl, webdavUrlInternal (redisUrl is intentionally empty)
|
GET /urls # baseUrl, apiUrl, webdavUrl, webdavUrlInternal (redisUrl is always "", kept for compatibility)
|
||||||
GET /neighbors # neighbours heard over UDP (beacon v2)
|
GET /neighbors # neighbours heard over UDP (beacon v2)
|
||||||
GET /api/neighbors # same, under /api
|
GET /api/neighbors # same, under /api
|
||||||
GET /api/stats # dashboard counters (see below)
|
GET /api/stats # dashboard counters (see below)
|
||||||
@@ -196,9 +203,9 @@ Every headline counter in one request, split by cost:
|
|||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"records": 88709, // SCARD file:__index__ — metadata roots, file or not
|
"records": 88709, // count of idx:roots/* — metadata roots, file or not
|
||||||
"redisKeys": 3115447, // DBSIZE — the flat per-field keys dominate
|
"redisKeys": 3115447, // kv row count (name kept for compatibility; cached 60 s)
|
||||||
"redisMemory": "588.74M",
|
"redisMemory": "588.74M",// DB + WAL file size (name kept for compatibility)
|
||||||
"identities": 3, // signing accounts on disk
|
"identities": 3, // signing accounts on disk
|
||||||
"files": { "count": 4864, "totalSize": 64587914424 }, // null until first sweep
|
"files": { "count": 4864, "totalSize": 64587914424 }, // null until first sweep
|
||||||
"udlUsers": 5, // accounts holding User Data Layer records
|
"udlUsers": 5, // accounts holding User Data Layer records
|
||||||
@@ -207,11 +214,11 @@ Every headline counter in one request, split by cost:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The first four are O(1) commands and are live on every call. `files` and
|
`records` and `identities` are live on every call; `redisKeys` is cached 60 s. `files` and
|
||||||
`udlUsers` need a keyspace walk (~0.5s and ~3s respectively on an 88k-record
|
`udlUsers` need a keyspace walk (~0.5s and ~3s respectively on an 88k-record
|
||||||
box), so they are served from a single-flight cache refreshed in the background
|
box), so they are served from a single-flight cache refreshed in the background
|
||||||
with a 60s TTL — the walk is **never** on the request path, because the
|
with a 60s TTL — the walk is **never** on the request path, because the
|
||||||
dashboard polls and ten open tabs must not cost ten Redis walks. Prefer this
|
dashboard polls and ten open tabs must not cost ten keyspace walks. Prefer this
|
||||||
over `/api/kv/info`, which does one `GET` per root (6s+) and sums `sizeByte`
|
over `/api/kv/info`, which does one `GET` per root (6s+) and sums `sizeByte`
|
||||||
across records that share a file.
|
across records that share a file.
|
||||||
|
|
||||||
@@ -266,10 +273,10 @@ PUT /api/metadata/{hashId}/property
|
|||||||
|
|
||||||
GET /api/kv/info
|
GET /api/kv/info
|
||||||
GET /api/kv/keys
|
GET /api/kv/keys
|
||||||
GET /api/kv/tree
|
GET /api/kv/tree # cursor is an opaque next-key ("0" when exhausted)
|
||||||
GET /api/kv/search
|
GET /api/kv/search
|
||||||
GET /api/kv/find
|
GET /api/kv/find
|
||||||
GET /api/kv/value
|
GET /api/kv/value # type is always "string" for an existing key
|
||||||
PUT /api/kv/value
|
PUT /api/kv/value
|
||||||
DELETE /api/kv/value
|
DELETE /api/kv/value
|
||||||
GET /api/kv/key/{key...}
|
GET /api/kv/key/{key...}
|
||||||
@@ -284,7 +291,8 @@ POST /api/snapshot/wipe
|
|||||||
|
|
||||||
### SSE event streams
|
### SSE event streams
|
||||||
|
|
||||||
The HTTP-mirror of the Redis Streams. External services never read Redis
|
The HTTP-mirror of the store's event streams (Redis Streams until 2026-10;
|
||||||
|
IDs keep the `<ms>-<seq>` form). External services never read the store
|
||||||
directly — they subscribe here with `Last-Event-ID` for resume. See
|
directly — they subscribe here with `Last-Event-ID` for resume. See
|
||||||
[`docs/api-mediated-access.md`](docs/api-mediated-access.md) for the wire
|
[`docs/api-mediated-access.md`](docs/api-mediated-access.md) for the wire
|
||||||
contract (one event per stream entry, opaque IDs, heartbeats, gap
|
contract (one event per stream entry, opaque IDs, heartbeats, gap
|
||||||
@@ -318,7 +326,7 @@ GET /api/identity/aead-key
|
|||||||
|
|
||||||
Records arrive signed (private-tier ones encrypted) as opaque base64 CBOR;
|
Records arrive signed (private-tier ones encrypted) as opaque base64 CBOR;
|
||||||
writes are version-gated (stale → `409 stale_version`, or `accepted:false` per
|
writes are version-gated (stale → `409 stale_version`, or `accepted:false` per
|
||||||
cell in a batch). Redis model in `internal/storage/udl.go`.
|
cell in a batch). Storage model in `internal/storage/udl.go` (`udl:rec:<uid>/<cid>/<key>` → JSON `{record,version,ts,value?}`; index sets as one key per member).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GET /api/udl/record?uid&cid&key
|
GET /api/udl/record?uid&cid&key
|
||||||
@@ -366,24 +374,21 @@ The legacy `/api/scan/trigger` and `/api/scan/status` still respond but
|
|||||||
return a deprecation pointer at the `/api/watchers/*` routes
|
return a deprecation pointer at the `/api/watchers/*` routes
|
||||||
(`internal/watcher/handlers.go:32-33`).
|
(`internal/watcher/handlers.go:32-33`).
|
||||||
|
|
||||||
### Admin / WebDAV
|
### WebDAV
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/admin/migrate-dual-roots # reunify stranded midhash-rooted entries
|
|
||||||
POST /api/admin/migrate-domain-screen # domain film|tv → screen, workForm backfill (idempotent)
|
|
||||||
POST /api/admin/migrate-user-cids # backfill udl:idx:user:<uid>:cids; until run, pre-index
|
|
||||||
# profiles export as EMPTY. Returns {fixed}
|
|
||||||
POST /api/admin/sweep-literature-echoes[?apply=true]
|
|
||||||
# strip query echoes off literature cards: anilistid on site cards,
|
|
||||||
# workcid/chapterNumber/volumeNumber/chapterStart/chapterEnd on any card.
|
|
||||||
# Dry-run unless apply=true; returns {dryRun, planned, fixed, byKey}
|
|
||||||
GET/PUT/DELETE /webdav/... # mounted on /files
|
GET/PUT/DELETE /webdav/... # mounted on /files
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The one-shot Redis-era admin migrations (`/api/admin/migrate-dual-roots`,
|
||||||
|
`migrate-domain-screen`, `migrate-user-cids`, `sweep-literature-echoes`) were
|
||||||
|
removed with the 2026-10 SQLite switch; they had been run once on
|
||||||
|
watch.nsl.sh before the migration.
|
||||||
|
|
||||||
No `/metrics` endpoint is exposed; observability is via container logs and
|
No `/metrics` endpoint is exposed; observability is via container logs and
|
||||||
the `/health` / `/status` JSON.
|
the `/health` / `/status` JSON.
|
||||||
|
|
||||||
## Redis-volume mutex
|
## DB-file mutex
|
||||||
|
|
||||||
`docker/leader-election.sh` is a bash flock loop, not a Go-level state
|
`docker/leader-election.sh` is a bash flock loop, not a Go-level state
|
||||||
machine. When `flock` succeeds on `/meta-core/locks/kv-leader.lock`, the
|
machine. When `flock` succeeds on `/meta-core/locks/kv-leader.lock`, the
|
||||||
@@ -392,8 +397,8 @@ retries every `ELECTION_RETRY_SECS` and stays dormant. When the process tree
|
|||||||
dies, the kernel releases the lock. There is no follower path inside the Go
|
dies, the kernel releases the lock. There is no follower path inside the Go
|
||||||
binary.
|
binary.
|
||||||
|
|
||||||
The lock is a **mutex, not service discovery** — it only stops two Redis
|
The lock is a **mutex, not service discovery** — it only stops two
|
||||||
writers landing on one RDB/AOF. Nothing reads it, and the `kv-leader.info`
|
writers landing on one database file (`db/meta-core.db`). Nothing reads it, and the `kv-leader.info`
|
||||||
file it used to publish is gone: siblings locate meta-core over UDP
|
file it used to publish is gone: siblings locate meta-core over UDP
|
||||||
(see [beacon-v2.md](../../docs/project-architecture/beacon-v2.md)).
|
(see [beacon-v2.md](../../docs/project-architecture/beacon-v2.md)).
|
||||||
`internal/leader` keeps its name for history, but only builds the `/urls`
|
`internal/leader` keeps its name for history, but only builds the `/urls`
|
||||||
@@ -402,8 +407,8 @@ and the UDP announce share.
|
|||||||
|
|
||||||
## Metadata storage shape (one-line summary)
|
## Metadata storage shape (one-line summary)
|
||||||
|
|
||||||
Roots are opaque UUIDv7 strings; every property is a separate Redis
|
Roots are opaque UUIDv7 strings; every property is a separate row
|
||||||
STRING at `file:<uuid>/<property>`; every known CID is a key-set member
|
of the `kv` table at `file:<uuid>/<property>`; every known CID is a key-set member
|
||||||
`file:<uuid>/cids/<bareCid>` plus a reverse-index entry `cid:<bareCid> → <uuid>`
|
`file:<uuid>/cids/<bareCid>` plus a reverse-index entry `cid:<bareCid> → <uuid>`
|
||||||
(the canonical CID is derived by rank on read, never stored). Full details in
|
(the canonical CID is derived by rank on read, never stored). Full details in
|
||||||
[`docs/metadata-storage-structure.md`](docs/metadata-storage-structure.md)
|
[`docs/metadata-storage-structure.md`](docs/metadata-storage-structure.md)
|
||||||
@@ -415,16 +420,16 @@ the repo-root [`METADATA_KEYS.md`](../../METADATA_KEYS.md).
|
|||||||
|
|
||||||
| Package | Purpose |
|
| Package | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `cmd/meta-core` | Entry point; Redis connect + schema sentinel + UDP discovery + service wiring. |
|
| `cmd/meta-core` | Entry point; store open + schema sentinel + UDP discovery + service wiring. |
|
||||||
| `internal/config` | Env-driven configuration, path helpers. |
|
| `internal/config` | Env-driven configuration, path helpers. |
|
||||||
| `internal/leader` | `LeaderInfoProvider` — builds the `/urls` payload from hostname/IP/config; no election logic. |
|
| `internal/leader` | `LeaderInfoProvider` — builds the `/urls` payload from hostname/IP/config; no election logic. |
|
||||||
| `internal/meshdisco` | beacon v2 node (advertise, probe, neighbour map) — the Go port of the spec. |
|
| `internal/meshdisco` | beacon v2 node (advertise, probe, neighbour map) — the Go port of the spec. |
|
||||||
| `internal/storage` | Redis wrapper (`client.go`), UUIDv7 minting, CID reverse index (`cid_resolution.go`), schema sentinel, field indexes + search-index bulk reads, UDL store (`udl.go`, `udl_admin.go`), stats, one-shot migrations/sweeps. |
|
| `internal/kv` | Storage engine: SQLite `kv` table, single writer + group commit, reader pool, in-transaction change events (`kv.go`), event streams as rows (`stream.go`). |
|
||||||
|
| `internal/storage` | Flat-key records (`client.go`), UUIDv7 minting, CID reverse index (`cid_resolution.go`), schema sentinel, search over `kv_field_v` + FTS (`search.go`), derived counters (`onChange`), UDL store (`udl.go`, `udl_admin.go`), stats. |
|
||||||
| `internal/cid` | CID parsing + rank ladder for canonical-CID selection (`cid-rank-vectors.json`, guarded by the meta-root `scripts/check-cid-vectors.sh`). |
|
| `internal/cid` | CID parsing + rank ladder for canonical-CID selection (`cid-rank-vectors.json`, guarded by the meta-root `scripts/check-cid-vectors.sh`). |
|
||||||
| `internal/identity` | Multi-account secp256k1 keystore, challenge/proof-of-possession, signature verification. |
|
| `internal/identity` | Multi-account secp256k1 keystore, challenge/proof-of-possession, signature verification. |
|
||||||
| `internal/events` | Keyspace-notification → `meta:events` stream publisher (`meta_publisher.go`). |
|
| `internal/api` | HTTP server + all handlers + SSE event endpoints + search matchers + `/api/stats`. |
|
||||||
| `internal/api` | HTTP server + all handlers + SSE event endpoints + in-memory search index + `/api/stats`. |
|
| `internal/schema` | Per-field schema, folded from counters the write path keeps exact (`idx:schema/…`). |
|
||||||
| `internal/schema` | Live per-field schema indexer (consumes `meta:events`). |
|
|
||||||
| `internal/snapshot` | Snapshot export / import / wipe. |
|
| `internal/snapshot` | Snapshot export / import / wipe. |
|
||||||
| `internal/watcher` | File system scanner, MidHash256 computation, dispatcher, state registry. |
|
| `internal/watcher` | File system scanner, MidHash256 computation, dispatcher, state registry. |
|
||||||
| `internal/watchers` | Polling-based watcher configurations (manager + poller + handlers). |
|
| `internal/watchers` | Polling-based watcher configurations (manager + poller + handlers). |
|
||||||
@@ -436,15 +441,17 @@ the repo-root [`METADATA_KEYS.md`](../../METADATA_KEYS.md).
|
|||||||
```
|
```
|
||||||
1. Load configuration from environment
|
1. Load configuration from environment
|
||||||
2. Build LeaderInfoProvider (no election — the bash gate already won)
|
2. Build LeaderInfoProvider (no election — the bash gate already won)
|
||||||
3. Connect to local Redis (retry up to 30× / 1s)
|
3. Open the SQLite store (META_CORE_DB_PATH or /meta-core/db/meta-core.db)
|
||||||
4. EnsureSchemaVersion — abort if the existing Redis layout is stale
|
4. EnsureSchemaVersion — abort if the existing store layout is stale
|
||||||
5. Start beacon v2 node (unless ENABLE_UDP_DISCOVERY=false)
|
5. Start beacon v2 node (unless ENABLE_UDP_DISCOVERY=false)
|
||||||
6. Construct API server (identity keystore migration, watcher, watchers,
|
6. Construct API server (identity keystore migration, watcher, watchers,
|
||||||
mounts, WebDAV)
|
mounts, WebDAV)
|
||||||
7. Start API server + pollers + search-index refresh loop; start
|
7. EnsureCounters — one-time build of the derived counters (record count,
|
||||||
MetaPublisher + schema Indexer, then warm field indexes and republish
|
per-field schema) on a store that predates them; blocking, ~1 min at 169k
|
||||||
existing metadata onto meta:events in the background
|
records, then never again
|
||||||
8. Wait for SIGINT/SIGTERM, shutdown in reverse order
|
8. Start API server + pollers (no republish: consumers that want the whole
|
||||||
|
state ask /api/events/meta for `0-0` and get a snapshot)
|
||||||
|
9. Wait for SIGINT/SIGTERM, shutdown in reverse order
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
@@ -452,13 +459,12 @@ the repo-root [`METADATA_KEYS.md`](../../METADATA_KEYS.md).
|
|||||||
| Dependency | Purpose |
|
| Dependency | Purpose |
|
||||||
|------------|---------|
|
|------------|---------|
|
||||||
| `github.com/gorilla/mux` | HTTP router |
|
| `github.com/gorilla/mux` | HTTP router |
|
||||||
| `github.com/redis/go-redis/v9` | Redis client |
|
| `modernc.org/sqlite` | Embedded SQLite (pure Go, CGO off) |
|
||||||
| `github.com/google/uuid` | UUIDv7 minting |
|
| `github.com/google/uuid` | UUIDv7 minting |
|
||||||
| `github.com/decred/dcrd/dcrec/secp256k1/v4`, `golang.org/x/crypto`, `github.com/mr-tron/base58` | Identity keys, signing, AEAD key derivation |
|
| `github.com/decred/dcrd/dcrec/secp256k1/v4`, `golang.org/x/crypto`, `github.com/mr-tron/base58` | Identity keys, signing, AEAD key derivation |
|
||||||
| `golang.org/x/net` | WebDAV support |
|
| `golang.org/x/net` | WebDAV support |
|
||||||
| `github.com/alicebob/miniredis/v2` | In-memory Redis for tests |
|
|
||||||
|
|
||||||
Runtime requirements: Go 1.21+ at build time; the container ships Redis,
|
Runtime requirements: Go 1.21+ at build time; the container ships
|
||||||
rclone, nginx, and supervisord alongside the binary, plus the React dashboard
|
rclone, nginx, and supervisord alongside the binary, plus the React dashboard
|
||||||
and metadata editor (built from `dashboard/` and `editor/` in the Dockerfile).
|
and metadata editor (built from `dashboard/` and `editor/` in the Dockerfile).
|
||||||
|
|
||||||
|
|||||||
+32
-26
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Version is set at build time
|
// Version is set at build time
|
||||||
var Version = "1.0.0"
|
var Version = "2.0.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
@@ -30,36 +31,41 @@ func main() {
|
|||||||
// Create leader info provider (Go binary only runs as leader - followers loop in bash)
|
// Create leader info provider (Go binary only runs as leader - followers loop in bash)
|
||||||
leaderProvider := leader.NewLeaderInfoProvider(cfg)
|
leaderProvider := leader.NewLeaderInfoProvider(cfg)
|
||||||
|
|
||||||
// Connect to local Redis (supervisord starts Redis before meta-core)
|
// Open the two embedded SQLite files: truth (signed claims, backed up)
|
||||||
// Retry connection since Redis may still be loading its dataset
|
// and the cache (everything derived, rebuildable). No database process.
|
||||||
redisURL := fmt.Sprintf("redis://localhost:%d", cfg.RedisPort)
|
if err := os.MkdirAll(filepath.Dir(cfg.CachePath()), 0o755); err != nil {
|
||||||
log.Printf("[meta-core] Connecting to Redis at %s", redisURL)
|
log.Fatalf("[meta-core] Failed to create the cache folder: %v", err)
|
||||||
|
|
||||||
storageClient := storage.NewClient("")
|
|
||||||
maxRetries := 30
|
|
||||||
retryDelay := 1 // seconds
|
|
||||||
var err error
|
|
||||||
for i := 0; i < maxRetries; i++ {
|
|
||||||
err = storageClient.Connect(redisURL)
|
|
||||||
if err == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
log.Printf("[meta-core] Redis connection attempt %d/%d failed: %v", i+1, maxRetries, err)
|
|
||||||
time.Sleep(time.Duration(retryDelay) * time.Second)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
storageClient := storage.NewClient()
|
||||||
log.Fatalf("[meta-core] Failed to connect to Redis after %d attempts: %v", maxRetries, err)
|
if err := storageClient.Connect(cfg.DBPath(), cfg.CachePath()); err != nil {
|
||||||
|
log.Fatalf("[meta-core] Failed to open the metadata store: %v", err)
|
||||||
}
|
}
|
||||||
log.Println("[meta-core] Connected to Redis")
|
|
||||||
|
|
||||||
// Schema-version sentinel: alpha clean-wipe gate. Refuses to boot when
|
// Schema-version gate: refuses a v1.x store (which the default truth path
|
||||||
// the existing Redis store predates the UUID-rooted schema, so legacy
|
// still points at) and any unknown layout. No migration code here — see
|
||||||
// data can't silently leak into the new code paths. See
|
// tools/claims-migrate.
|
||||||
// docs/uuid-rooted-metadata.md.
|
|
||||||
if err := storageClient.EnsureSchemaVersion(storage.SchemaVersion); err != nil {
|
if err := storageClient.EnsureSchemaVersion(storage.SchemaVersion); err != nil {
|
||||||
log.Fatalf("[meta-core] %v", err)
|
log.Fatalf("[meta-core] %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("[meta-core] Redis schema version OK (v%d)", storage.SchemaVersion)
|
log.Printf("[meta-core] Store schema version OK (v%d)", storage.SchemaVersion)
|
||||||
|
|
||||||
|
// Bring the cache level with truth (replay after a crash, full rebuild
|
||||||
|
// when the cache folder is empty), then start the apply loop.
|
||||||
|
started := time.Now()
|
||||||
|
if err := storageClient.Start(); err != nil {
|
||||||
|
log.Fatalf("[meta-core] %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("[meta-core] Cache ready in %s", time.Since(started).Round(time.Millisecond))
|
||||||
|
|
||||||
|
// Derived counters (record count, per-field schema) are kept exact by the
|
||||||
|
// write path. A store that predates them gets a one-time build here,
|
||||||
|
// before anything else writes.
|
||||||
|
started = time.Now()
|
||||||
|
if built, err := storageClient.EnsureCounters(); err != nil {
|
||||||
|
log.Fatalf("[meta-core] Failed to build the store's derived counters: %v", err)
|
||||||
|
} else if built {
|
||||||
|
log.Printf("[meta-core] Built derived counters in %s", time.Since(started).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
|
||||||
// Beacon v2 (docs/project-architecture/beacon-v2.md): advertise meta-core
|
// Beacon v2 (docs/project-architecture/beacon-v2.md): advertise meta-core
|
||||||
// as metamesh.core — carrying the /urls block services locate us by — plus
|
// as metamesh.core — carrying the /urls block services locate us by — plus
|
||||||
@@ -101,7 +107,7 @@ func main() {
|
|||||||
return "running", []meshdisco.Resource{core, service}
|
return "running", []meshdisco.Resource{core, service}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err := mesh.Start(); err != nil {
|
if err := mesh.Start(); err != nil {
|
||||||
log.Printf("[meta-core] Warning: UDP discovery disabled: %v", err)
|
log.Printf("[meta-core] Warning: UDP discovery disabled: %v", err)
|
||||||
mesh = nil
|
mesh = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,9 +215,9 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Tile
|
<Tile
|
||||||
label="Redis keys"
|
label="Store keys"
|
||||||
value={formatCount(stats?.redisKeys)}
|
value={formatCount(stats?.redisKeys)}
|
||||||
note={stats?.redisMemory ? `${stats.redisMemory} in use` : undefined}
|
note={stats?.redisMemory ? `${stats.redisMemory} on disk` : undefined}
|
||||||
/>
|
/>
|
||||||
<Tile
|
<Tile
|
||||||
label="Identities"
|
label="Identities"
|
||||||
@@ -239,7 +239,7 @@ export default function Dashboard() {
|
|||||||
{health.status === 'ok' ? 'Healthy' : health.status}
|
{health.status === 'ok' ? 'Healthy' : health.status}
|
||||||
</div>
|
</div>
|
||||||
<p style={noteStyle}>
|
<p style={noteStyle}>
|
||||||
Redis: {health.redis ? 'Connected' : 'Disconnected'}
|
Metadata store: {health.redis ? 'Open' : 'Unavailable'}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -179,11 +179,10 @@ async function fetchAccounts(): Promise<Account[]> {
|
|||||||
// Resolve an account's display name from the User Data Layer, or null.
|
// Resolve an account's display name from the User Data Layer, or null.
|
||||||
//
|
//
|
||||||
// Never throws and never reports an error upward: a name is decoration on top
|
// Never throws and never reports an error upward: a name is decoration on top
|
||||||
// of the uid, and the account list must render identically whether Redis is up
|
// of the uid, and the account list must render identically whether the store is up
|
||||||
// (identity itself is file-backed, the UDL is not). The name is also *unsigned*
|
// (identity itself is file-backed, the UDL is not). The name is the user's
|
||||||
// — meta-core stores the plaintext beside the signed record and verifies
|
// signed MMUR2 claim (meta-core verified it on write); the uid stays on screen
|
||||||
// nothing — so the uid stays on screen as the real identity, and the length cap
|
// as the real identity, and the length cap bounds the layout.
|
||||||
// bounds what a hostile writer can do to the layout.
|
|
||||||
async function fetchDisplayName(uid: string): Promise<string | null> {
|
async function fetchDisplayName(uid: string): Promise<string | null> {
|
||||||
const url =
|
const url =
|
||||||
`/api/udl/record?uid=${encodeURIComponent(uid)}` +
|
`/api/udl/record?uid=${encodeURIComponent(uid)}` +
|
||||||
@@ -192,12 +191,11 @@ async function fetchDisplayName(uid: string): Promise<string | null> {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) return null; // 503 when storage is down, 500, …
|
if (!res.ok) return null; // 503 when storage is down, 500, …
|
||||||
const data = (await res.json()) as { exists?: boolean; value?: unknown };
|
const data = (await res.json()) as { exists?: boolean; claim?: { value?: unknown } | null };
|
||||||
// Key off `value`, not `exists`: clearing a name writes a tombstone, which
|
// A retraction (null) or ciphertext ({b64}) has nothing to show.
|
||||||
// leaves the cell existing with no plaintext. Private-tier cells look the
|
const value = data.claim?.value;
|
||||||
// same, which is exactly right — there is nothing to show for either.
|
if (typeof value !== 'string') return null;
|
||||||
if (typeof data.value !== 'string') return null;
|
const name = value.trim();
|
||||||
const name = data.value.trim();
|
|
||||||
return name ? name.slice(0, 64) : null;
|
return name ? name.slice(0, 64) : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -209,7 +207,7 @@ async function fetchDisplayName(uid: string): Promise<string | null> {
|
|||||||
// so per-row calls would walk the keyspace once per account.
|
// so per-row calls would walk the keyspace once per account.
|
||||||
//
|
//
|
||||||
// Never throws, for the same reason fetchDisplayName does not: the counts are
|
// Never throws, for the same reason fetchDisplayName does not: the counts are
|
||||||
// context on top of the uid, and the list must render identically when Redis is
|
// context on top of the uid, and the list must render identically when the store is
|
||||||
// down — accounts are file-backed, their data is not.
|
// down — accounts are file-backed, their data is not.
|
||||||
//
|
//
|
||||||
// Returns null on failure rather than an empty map. The difference is
|
// Returns null on failure rather than an empty map. The difference is
|
||||||
@@ -603,7 +601,7 @@ function AccountCard({
|
|||||||
records === 1 ? 'record' : 'records'
|
records === 1 ? 'record' : 'records'
|
||||||
} — likes, ratings, watch progress across ${titles.toLocaleString()} ${
|
} — likes, ratings, watch progress across ${titles.toLocaleString()} ${
|
||||||
titles === 1 ? 'title' : 'titles'
|
titles === 1 ? 'title' : 'titles'
|
||||||
} — are permanently deleted from Redis, along with every index that names this uid.`}{' '}
|
} — are permanently deleted from the metadata store, along with every index that names this uid.`}{' '}
|
||||||
This cannot be undone and there is no backup.
|
This cannot be undone and there is no backup.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -655,12 +653,12 @@ export default function Identity() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (gen !== reqGen.current) return;
|
if (gen !== reqGen.current) return;
|
||||||
// Render the list before resolving names, so a down Redis delays nothing.
|
// Render the list before resolving names, so a down store delays nothing.
|
||||||
setAccounts(list);
|
setAccounts(list);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
// Names and counts both come after the list is on screen, and neither can
|
// Names and counts both come after the list is on screen, and neither can
|
||||||
// fail the page: a down Redis costs decoration, not the account list.
|
// fail the page: a down store costs decoration, not the account list.
|
||||||
const [settled, counts] = await Promise.all([
|
const [settled, counts] = await Promise.all([
|
||||||
Promise.allSettled(list.map((a) => fetchDisplayName(a.uid))),
|
Promise.allSettled(list.map((a) => fetchDisplayName(a.uid))),
|
||||||
fetchStats(),
|
fetchStats(),
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ set -e
|
|||||||
|
|
||||||
# Create required directories
|
# Create required directories
|
||||||
mkdir -p /meta-core/locks
|
mkdir -p /meta-core/locks
|
||||||
mkdir -p /meta-core/db/redis
|
mkdir -p /meta-core/db
|
||||||
|
mkdir -p /meta-core/db-cache
|
||||||
mkdir -p /meta-core/mounts/errors
|
mkdir -p /meta-core/mounts/errors
|
||||||
mkdir -p /files
|
mkdir -p /files
|
||||||
mkdir -p /var/log/supervisor
|
mkdir -p /var/log/supervisor
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
#
|
#
|
||||||
# The flock here is a MUTEX, not service discovery. It is the only thing
|
# The flock here is a MUTEX, not service discovery. It is the only thing
|
||||||
# stopping two containers that mount the same /meta-core volume from putting
|
# stopping two containers that mount the same /meta-core volume from putting
|
||||||
# two Redis writers on one RDB/AOF. Sibling services no longer read anything
|
# two writers on one database file (/meta-core/db/meta-core.db). Sibling
|
||||||
# from this directory — they locate meta-core over UDP (meta-discovery v1, see
|
# services no longer read anything from this directory — they locate meta-core
|
||||||
# docs/project-architecture/service-discovery.md), so the kv-leader.info file
|
# over UDP (beacon v2, see docs/project-architecture/beacon-v2.md), so the
|
||||||
|
# kv-leader.info file
|
||||||
# this script used to publish is gone. Keep the lock; it costs nothing and the
|
# this script used to publish is gone. Keep the lock; it costs nothing and the
|
||||||
# corruption it prevents is unrecoverable.
|
# corruption it prevents is unrecoverable.
|
||||||
#
|
#
|
||||||
|
|||||||
+3
-17
@@ -16,26 +16,13 @@ serverurl=unix:///var/run/supervisor.sock
|
|||||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Redis: Key-value store (managed by supervisord, started before meta-core)
|
# meta-core: HTTP API, Mount API, File Watcher, metadata store (embedded SQLite)
|
||||||
# Previously spawned by Go code, now managed by bash-based leader election
|
|
||||||
# =============================================================================
|
|
||||||
[program:redis]
|
|
||||||
command=/usr/bin/redis-server --port %(ENV_REDIS_PORT)s --bind 0.0.0.0 --protected-mode no --dir /meta-core/db/redis --appendonly yes --appendfilename appendonly.aof --dbfilename dump.rdb --save 60 1 --loglevel warning --notify-keyspace-events Kg$
|
|
||||||
priority=1
|
|
||||||
autostart=true
|
|
||||||
autorestart=true
|
|
||||||
stdout_logfile=/dev/stdout
|
|
||||||
stdout_logfile_maxbytes=0
|
|
||||||
stderr_logfile=/dev/stderr
|
|
||||||
stderr_logfile_maxbytes=0
|
|
||||||
startsecs=2
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# meta-core: HTTP API, Mount API, File Watcher (connects to local Redis)
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
[program:meta-core]
|
[program:meta-core]
|
||||||
command=/usr/local/bin/meta-core
|
command=/usr/local/bin/meta-core
|
||||||
priority=10
|
priority=10
|
||||||
|
; Room to finish in-flight writes and checkpoint the WAL on shutdown.
|
||||||
|
stopwaitsecs=60
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=true
|
autorestart=true
|
||||||
stdout_logfile=/dev/stdout
|
stdout_logfile=/dev/stdout
|
||||||
@@ -50,7 +37,6 @@ environment=
|
|||||||
SERVICE_VERSION="%(ENV_SERVICE_VERSION)s",
|
SERVICE_VERSION="%(ENV_SERVICE_VERSION)s",
|
||||||
META_CORE_HTTP_PORT="%(ENV_META_CORE_HTTP_PORT)s",
|
META_CORE_HTTP_PORT="%(ENV_META_CORE_HTTP_PORT)s",
|
||||||
META_CORE_HTTP_HOST="%(ENV_META_CORE_HTTP_HOST)s",
|
META_CORE_HTTP_HOST="%(ENV_META_CORE_HTTP_HOST)s",
|
||||||
REDIS_PORT="%(ENV_REDIS_PORT)s",
|
|
||||||
WATCH_INTERVAL_MS="%(ENV_WATCH_INTERVAL_MS)s",
|
WATCH_INTERVAL_MS="%(ENV_WATCH_INTERVAL_MS)s",
|
||||||
API_PORT="80",
|
API_PORT="80",
|
||||||
BASE_URL="%(ENV_BASE_URL)s"
|
BASE_URL="%(ENV_BASE_URL)s"
|
||||||
|
|||||||
+265
-36
@@ -1,4 +1,14 @@
|
|||||||
# API-Mediated Access (Redis Lockdown)
|
# API-Mediated Access (Redis Lockdown → Signed Claims)
|
||||||
|
|
||||||
|
> **meta-core v2.0.0 — the claims model.** The implementation basis is
|
||||||
|
> [`docs/implementation/metacore-claims-model.md`](../../../docs/implementation/metacore-claims-model.md)
|
||||||
|
> (meta-root). Where this file and that one disagree, that one wins. The
|
||||||
|
> *access* contract below still holds unchanged — HTTP only, events over
|
||||||
|
> SSE, no database reachable from outside `metacore-app`. The *write*
|
||||||
|
> contract changed: every metadata write is a **signed claim** posted to
|
||||||
|
> `POST /api/claims`, and the legacy unsigned write endpoints answer
|
||||||
|
> `410 Gone`. See ["Write contract — signed claims"](#write-contract--signed-claims-v200)
|
||||||
|
> below.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@@ -9,6 +19,16 @@ on [uuid-rooted-metadata.md](uuid-rooted-metadata.md), which assumes
|
|||||||
meta-core is the single writer to the metadata keyspace — an assumption
|
meta-core is the single writer to the metadata keyspace — an assumption
|
||||||
that's now actually enforced rather than aspirational.
|
that's now actually enforced rather than aspirational.
|
||||||
|
|
||||||
|
> **2026-10: meta-core no longer uses Redis at all.** Its store is now an
|
||||||
|
> embedded SQLite file (`/meta-core/db/meta-core.db`, see
|
||||||
|
> [metadata-storage-structure.md](metadata-storage-structure.md)) and the
|
||||||
|
> `file:events` / `meta:events` streams are rows of that store. The lockdown
|
||||||
|
> below is now structural — there is no database port anywhere — and the
|
||||||
|
> SSE wire contract was kept byte-for-byte, so no consumer changed. The
|
||||||
|
> rest of this doc is the design record of the Redis-era lockdown; the
|
||||||
|
> sections that describe *current* behaviour (SSE retention, reconnect,
|
||||||
|
> error handling) have been updated in place.
|
||||||
|
|
||||||
See "Implementation outcome" at the bottom for the delta between this doc's
|
See "Implementation outcome" at the bottom for the delta between this doc's
|
||||||
plan and what shipped (notably the write-path CID resolution and the
|
plan and what shipped (notably the write-path CID resolution and the
|
||||||
dual-root migration sweep, which weren't in the original plan but turned
|
dual-root migration sweep, which weren't in the original plan but turned
|
||||||
@@ -59,20 +79,23 @@ we don't fully control.
|
|||||||
## The architecture in one picture
|
## The architecture in one picture
|
||||||
|
|
||||||
```
|
```
|
||||||
meta-core (only Redis writer + reader)
|
meta-core v2.0.0 (only store writer + reader)
|
||||||
┌──────────────────────────────────────┐
|
┌──────────────────────────────────────┐
|
||||||
│ HTTP API surface: │
|
│ HTTP API surface: │
|
||||||
│ POST/PUT/PATCH /api/metadata/* │ ◄── writes
|
│ POST /api/claims │ ◄── writes (signed claims only)
|
||||||
│ GET /api/meta/{cid} │ ◄── reads (by CID)
|
│ POST /api/merge │ ◄── stateless merge, no write
|
||||||
│ GET /api/metadata/* │ ◄── reads (by id)
|
│ GET /api/meta/{cid} │ ◄── reads (by CID, merged)
|
||||||
│ GET /api/file/{cid} │ ◄── content
|
│ GET /api/meta/{cid}/claims │ ◄── reads (raw signed claims)
|
||||||
│ GET /api/events/files │ ◄── SSE stream
|
│ GET /api/metadata/* │ ◄── reads (by rid, merged)
|
||||||
│ GET /api/events/meta │ ◄── SSE stream
|
│ GET /api/file/{cid} │ ◄── content
|
||||||
|
│ GET /api/events/files │ ◄── SSE stream
|
||||||
|
│ GET /api/events/meta │ ◄── SSE stream (merged changes)
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ▼ │
|
│ ▼ verify sig → truth → apply │
|
||||||
│ Redis (file:*, cid:*, streams) │
|
│ truth /meta-core/db/meta-core.db │
|
||||||
│ ◄── container-local, no exposed │
|
│ cache /meta-core/db-cache/cache.db │
|
||||||
│ port, no docker-network alias │
|
│ ◄── embedded SQLite, no port, │
|
||||||
|
│ no docker-network alias │
|
||||||
└──────────────────────────────────────┘
|
└──────────────────────────────────────┘
|
||||||
▲ ▲
|
▲ ▲
|
||||||
│ HTTP │ HTTP SSE
|
│ HTTP │ HTTP SSE
|
||||||
@@ -85,9 +108,18 @@ we don't fully control.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**The single-writer rule.** meta-core has exclusive write authority over
|
**The single-writer rule.** meta-core has exclusive write authority over
|
||||||
the `file:*`, `cid:*`, `file:__index__`, and `meta-core:*` keyspaces. No
|
its store — in v2.0.0 the two SQLite databases (truth and cache); in the
|
||||||
other service can write — not because of an honoured convention, but
|
Redis era the `file:*`, `cid:*`, `file:__index__` (later `idx:*`), and
|
||||||
because they can't reach Redis at all.
|
`meta-core:*` keyspaces. No other service can write the store — not because
|
||||||
|
of an honoured convention, but because they can't reach it at all.
|
||||||
|
|
||||||
|
**What changed in v2.0.0 is who may *author* metadata.** meta-core is still
|
||||||
|
the only process that touches the database, but it no longer accepts
|
||||||
|
unsigned values: a writer submits signed votes (claims), meta-core verifies
|
||||||
|
each signature and stores the vote. meta-core **never signs a claim and
|
||||||
|
holds no writer key** — a store that could sign for everyone would prove
|
||||||
|
nothing. The signature is the write authentication; there are no
|
||||||
|
per-writer tokens.
|
||||||
|
|
||||||
**Stream reads also mediated.** Events go out through SSE endpoints on
|
**Stream reads also mediated.** Events go out through SSE endpoints on
|
||||||
meta-core's HTTP surface. Consumers don't see the underlying Redis
|
meta-core's HTTP surface. Consumers don't see the underlying Redis
|
||||||
@@ -98,6 +130,147 @@ without breaking every consumer.
|
|||||||
|
|
||||||
**Redis is private.** No host port mapping, no docker-network alias, no
|
**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.
|
URL in `kv-leader.info`. Discovery returns only meta-core's HTTP base.
|
||||||
|
(Since 2026-10 there is no Redis at all; the SQLite files are reachable only
|
||||||
|
from inside `metacore-app`.)
|
||||||
|
|
||||||
|
## Write contract — signed claims (v2.0.0)
|
||||||
|
|
||||||
|
Every metadata value is a **signed vote**: *signer `uid` says `key = value`
|
||||||
|
about content `cid`* — `claim(uid, cid, key) → value + ts + sig`. The
|
||||||
|
record a reader sees is the **merged view** that meta-core precomputes from
|
||||||
|
the votes; a reader's own votes always win in their view. Full model,
|
||||||
|
schema and merge rules: the
|
||||||
|
[implementation basis](../../../docs/implementation/metacore-claims-model.md)
|
||||||
|
§1–§7.
|
||||||
|
|
||||||
|
### Endpoint table
|
||||||
|
|
||||||
|
| | Endpoint | Behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| **Write** | `POST /api/claims` | A batch of signed claims (a JSON array, or `{"claims": [...]}`). **The only metadata write path.** |
|
||||||
|
| Removed | `PUT/PATCH/DELETE /meta/{hash}`, `PUT/DELETE /meta/{hash}/{key...}`, `POST /meta/{hash}/_add/{key...}`, `PUT/DELETE /api/metadata/{hashId}`, `PUT /api/metadata/{hashId}/property`, `POST /api/metadata/batch` | `410 Gone`, with a pointer to `POST /api/claims` |
|
||||||
|
| Kept | `PUT/GET/DELETE /api/kv/value` | Service scratch KV (`btmagnet:*`, `searchcov:*`), plain rows of the cache database's `kv` table (any key outside the reserved prefixes `file:`, `cid:`, `idx:`, `dup:`, `ev:`, `evhwm:`, `meta:`, `meta-core:`) — losing it only costs a refetch |
|
||||||
|
| Kept | `POST /api/files/register`, the file watcher | Fill `local_file` (box-local `filePath`, size, mtime, midhash — unsigned) and `cid_group` |
|
||||||
|
| UDL facade | `/api/udl/*` (same paths) | Writes go through the claims write path; reads come from the claims (per-signer index `claim_by_pid`). Records are MMUR2 claims. Shapes: see "UDL facade shapes" below |
|
||||||
|
| Read | `GET /api/meta/{cid}`, `GET /api/metadata/{rid}`, `GET /file/{cid}`, `GET /meta/{hash}`, `GET /api/metadata/hash-ids`, `GET /api/metadata/list`, `POST /api/metadata/search`, `GET /api/files/tuples`, `GET /api/schema`, `GET /api/stats` | Unchanged paths and response shapes, served from the merged view (+ `local_file`); optional `?as=<uid>`. They carry no `nsrc` |
|
||||||
|
| **New** | `GET /api/meta/{cid}/claims` | `{rid, cids, claims}`: the record's rid, the cids of its group and every live signed claim on them (all signers, tombstones excluded). `?key=` keeps one exact key, `?uid=` one signer. `404 unknown_cid` when no record holds the cid |
|
||||||
|
| **New** | `GET /api/metadata/{rid}/claims` | Same body and filters, addressed by rid (a cid is also accepted); `404` for an unknown record. What meta-search ingest and the gateway's corpus search use |
|
||||||
|
| **New** | `POST /api/merge` | Stateless: body `{claims, as?}`; verifies each claim (invalid ones dropped and counted, never a batch error), merges with the store's merge rule, overlays `as`'s own votes (in the request and held by this core for the same cids). Returns `{fields, nsrc, rejected}` — merged `key → value`, `key → agreeing signers`, rejected count. `413` above 10 000 claims. Writes nothing |
|
||||||
|
| Events | `GET /api/events/meta` | Unchanged `{type: set\|del, key, ts}` shape, now emitted for changes of the **merged** view, `key` still the literal `file:<rid>/<field>`; `GET /api/events/files` unchanged. Both streams are rows of the cache `kv` table (`ev:meta:events/…`, `ev:file:events/…`), ~100k entries each |
|
||||||
|
| Admin | `POST /api/metadata/clear` | Wipes both databases (truth and cache) |
|
||||||
|
| Removed | `/api/snapshot/export`, `/api/snapshot/import`, `/api/snapshot/wipe` | No snapshot format in v2.0.0 |
|
||||||
|
|
||||||
|
Endpoints the claims model does not touch keep their behaviour: discovery
|
||||||
|
(`/health`, `/status`, `/leader`, `/urls`, `/neighbors`), the account-key
|
||||||
|
identity service (`/api/identity/*` — its removal is a separate ticket;
|
||||||
|
v2.0.0 adds the `format` field to `sign` / `sign-batch` — `"compact"` or
|
||||||
|
`"der"`, default compact for `"MMUR2\n"` payloads and DER otherwise — and
|
||||||
|
makes `DELETE /api/identity` sign and store a retraction of every live
|
||||||
|
claim of the account before removing it),
|
||||||
|
`/api/file/{cid}/info`, `/file/cid`, `/data/{hash}*`, mounts, watchers and
|
||||||
|
WebDAV.
|
||||||
|
|
||||||
|
### The claim on the wire (JSON form)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "cid": "bafy…", "uid": "zQ3s…", "key": "title",
|
||||||
|
"value": "The Matrix", // or {"b64": "…"} for bytes, or null
|
||||||
|
"ts": 1793001234567,
|
||||||
|
"sig": "base64(64 bytes)" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `cid` | The subject: a content CID as the writer knows it (bare CID string, as in `cids/*`). Never a rid — rids are local and never signed |
|
||||||
|
| `uid` | The signer: `"z" + base58btc(33-byte compressed secp256k1 pubkey)` |
|
||||||
|
| `key` | The field (`METADATA_KEYS.md`); a key-set member is inside the key (`genres/Drama`) |
|
||||||
|
| `value` | Text = public plaintext; `{"b64": …}` = bytes, a private (ciphertext) envelope; `null` = retraction |
|
||||||
|
| `ts` | Milliseconds since epoch. It **is** the version: only increases per `(uid, cid, key)` |
|
||||||
|
| `sig` | MMUR2: secp256k1 ECDSA, compact 64 bytes, over SHA-256 of `"MMUR2\n" ‖ canonical CBOR [cid, uid, key, value, ts]` |
|
||||||
|
|
||||||
|
A batch (the `POST /api/claims` body) is a JSON array of these (or
|
||||||
|
`{"claims": [...]}`). Kamilata and
|
||||||
|
the gateway wire carry the same five fields plus `sig`; the signed bytes
|
||||||
|
never change on any hop, and relays (meta-search, a client persisting a
|
||||||
|
gateway hit) forward claims verbatim — they never re-sign.
|
||||||
|
|
||||||
|
### Per-claim statuses
|
||||||
|
|
||||||
|
`POST /api/claims` answers with one status per claim:
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `accepted` | Verified and stored (or a retraction stored as a tombstone) |
|
||||||
|
| `unchanged` | The stored row for `(uid, cid, key)` already has this `ts` and `sig` — **or the same value under any `ts`** (a signer re-asserting what it holds): no verification, no write, and the stored row keeps its older `ts` |
|
||||||
|
| `stale` | The stored `ts` is ≥ the incoming `ts`; ignored (blocks replay of old votes) |
|
||||||
|
| `bad_signature` | The MMUR2 signature does not verify against the `uid`'s public key |
|
||||||
|
| `bad_request` | The claim is malformed |
|
||||||
|
|
||||||
|
The response is `200` with a JSON array of `{status, reason?}`, one entry
|
||||||
|
per claim, in input order, even when some are refused. A batch above 10 000
|
||||||
|
claims answers `413 too_many_claims`; a body that is not claims JSON
|
||||||
|
`400 invalid_json`. The
|
||||||
|
removed write routes answer `410 {"error":"gone","use":"POST /api/claims"}`.
|
||||||
|
`POST /api/claims`, `POST /api/merge` and the two claims read routes are
|
||||||
|
container-to-container routes, not routed through Caddy.
|
||||||
|
|
||||||
|
Rules: every claim is signed — ciphertext and retractions included; an
|
||||||
|
unsigned or badly signed claim is refused. A retraction (`value: null`) is
|
||||||
|
kept as a tombstone, so a peer cannot replay the vote it retracted. Values
|
||||||
|
are stored exactly as signed; normalisation (trim + case-fold, no NFC)
|
||||||
|
happens only inside the merge.
|
||||||
|
|
||||||
|
Accepted claims commit to the truth database in one group-committed
|
||||||
|
transaction; an apply loop then folds them, in commit order, into the
|
||||||
|
cache (the merged `file:<rid>/<field>` rows of its v1-layout `kv` table,
|
||||||
|
FTS, counters, events). `POST /api/claims` answers only once the cache has
|
||||||
|
applied the batch, so the writer reads its own write; another reader may
|
||||||
|
briefly see the previous merged value — the cache is behind, never wrong.
|
||||||
|
|
||||||
|
### `?as=<uid>` — the viewer's overlay
|
||||||
|
|
||||||
|
Every merged read accepts an optional `?as=<uid>`. The merge then puts that
|
||||||
|
signer's own live text vote first for each key (read-time overlay found
|
||||||
|
through the per-signer index, never stored in the shared merged view). Without
|
||||||
|
`?as=` the read returns the shared merge: most distinct signers → oldest
|
||||||
|
`ts` → byte-wise smallest value; ciphertext never merges.
|
||||||
|
|
||||||
|
### Raw claims and client-side merging
|
||||||
|
|
||||||
|
The claims read routes (`GET /api/metadata/{rid}/claims` for meta-search
|
||||||
|
ingest and the gateway's corpus hits, `GET /api/meta/{cid}/claims` by cid)
|
||||||
|
return the raw signed votes of every signer, not a merged map. A client that receives
|
||||||
|
claims from the swarm or a gateway displays them by calling its **local**
|
||||||
|
meta-core's `POST /api/merge` (one merge implementation, in Go), and
|
||||||
|
persists a hit by posting the claims **as received** to `POST /api/claims`.
|
||||||
|
|
||||||
|
### UDL facade shapes
|
||||||
|
|
||||||
|
Claims in the JSON form above (`internal/api/udl.go`):
|
||||||
|
|
||||||
|
| Route | Request | Response |
|
||||||
|
|---|---|---|
|
||||||
|
| `PUT /api/udl/record` | one claim | `200 {accepted: true, status}` for `accepted` / `unchanged`; `409 stale_version` for `stale`; `400` (status as slug) for `bad_signature` / `bad_request` |
|
||||||
|
| `PUT /api/udl/records` | `{records: [claim, …]}` | `200 {results: [{key, accepted, status, error?}, …]}`, input order; `accepted` = `accepted` or `unchanged` |
|
||||||
|
| `GET /api/udl/record?uid=&cid=&key=` | — | `{exists, claim}` — the stored claim, tombstone included; `exists` false when absent or a tombstone |
|
||||||
|
| `GET /api/udl/user/{uid}/key/{key}`, `/user/{uid}/cid/{cid}` | — | `{entries: [claim, …]}` |
|
||||||
|
| `GET /api/udl/user/{uid}/all?after=&limit=` | — | `{entries: [claim, …], nextCid}` |
|
||||||
|
| `GET /api/udl/user/{uid}/count` | — | `{cids}` |
|
||||||
|
| `GET /api/udl/cid/{cid}/users` | — | `{users: [uid, …]}` |
|
||||||
|
| `GET /api/udl/cid/{cid}/aggregate?keys=` | — | `{aggregates: [{key, count, avg, trues, sample_size}]}` |
|
||||||
|
| `GET /api/udl/users/stats` | — | `{users: {uid: {records, cids, keys}}}` for **every signer** with live claims — plugin, feeder, gateway and service keys included. `/api/stats` `udlUsers` is the size of this map |
|
||||||
|
|
||||||
|
### What a writer needs
|
||||||
|
|
||||||
|
Each writing container (every meta-sort plugin, every self-publishing
|
||||||
|
feeder, gateway core, meta-sort, meta-share, meta-search) signs with its
|
||||||
|
**own** key; users sign in the browser. meta-core holds none of these keys.
|
||||||
|
Key configuration per signer: implementation basis §9.
|
||||||
|
|
||||||
|
Box-local file facts (`filePath`, size, `mtimeNano`, midhash) are not
|
||||||
|
claims: the watcher and `POST /api/files/register` write them into the
|
||||||
|
cache's `local_file` table, they are joined into the record view unsigned,
|
||||||
|
and they never appear in `/api/meta/{cid}/claims` or on the swarm.
|
||||||
|
|
||||||
## Eventing — HTTP Server-Sent Events
|
## Eventing — HTTP Server-Sent Events
|
||||||
|
|
||||||
@@ -243,6 +416,36 @@ After PR A, the cursor contract is **"durable across meta-core restart
|
|||||||
and bounded by `MAXLEN ~ N`"** — matching what consumers already assume
|
and bounded by `MAXLEN ~ N`"** — matching what consumers already assume
|
||||||
when they persist a Last-Event-ID.
|
when they persist a Last-Event-ID.
|
||||||
|
|
||||||
|
**Current backing store (since 2026-10).** The streams are rows of
|
||||||
|
meta-core's SQLite kv table — `ev:<stream>/<ms13>-<seq10>` → JSON of the
|
||||||
|
entry's fields, plus a high-water mark `evhwm:<stream>` that seeds the next
|
||||||
|
ID (so a `ClearStream` reset never reissues an ID an old cursor treats as
|
||||||
|
seen). The contract above holds unchanged:
|
||||||
|
|
||||||
|
- Entries persist across restart (they are in the database file).
|
||||||
|
- Retention is ~100k entries per stream, trimmed every 10k appends.
|
||||||
|
- IDs keep the Redis `<ms>-<seq>` form, so cursors persisted by meta-sort /
|
||||||
|
meta-share / meta-stremio before the switch still compare.
|
||||||
|
- `meta:events` entries (`{type: set|del, key, ts}`) describe changes of
|
||||||
|
the **merged** view (v2.0.0): the apply loop emits `set`/`del` (keyed by
|
||||||
|
rid + key) only when a claim actually changed a merged value, in the same
|
||||||
|
cache transaction as that change. A group merge (two cid groups linked by
|
||||||
|
a `cids/*` vote) emits `del` for every key of the losing rid and `set` for
|
||||||
|
the survivor. *History:* v1.x appended them in the same transaction as the
|
||||||
|
`file:` key change; before 2026-10 a Redis keyspace-notification hop
|
||||||
|
(PSUBSCRIBE in `MetaPublisher`) could drop events when it lagged.
|
||||||
|
- **Cache rebuild (v2.0.0).** Events live in the cache database. A cache
|
||||||
|
rebuild (missing cache folder, or catch-up after an unclean shutdown)
|
||||||
|
emits no per-key events; SSE consumers get `event: gap` and take a
|
||||||
|
snapshot, as they do for a trimmed cursor. Event IDs stay time-based, so
|
||||||
|
they keep rising across a rebuild.
|
||||||
|
- **`Last-Event-ID: 0` / `0-0` on `/api/events/meta` returns a snapshot**
|
||||||
|
(since v1.2.0): one `set` per existing record key, then the live tail from
|
||||||
|
the head captured before the snapshot. Only the last snapshot event carries
|
||||||
|
an `id:`. This replaced the boot-time republish of every field, which a
|
||||||
|
~100k-entry stream could never hold. A trimmed (non-zero) cursor still gets
|
||||||
|
the `gap` event and a replay of what is retained.
|
||||||
|
|
||||||
### Heartbeats
|
### Heartbeats
|
||||||
|
|
||||||
Server writes an SSE comment line (`:keep-alive\n\n`) every 30 seconds
|
Server writes an SSE comment line (`:keep-alive\n\n`) every 30 seconds
|
||||||
@@ -254,10 +457,13 @@ per the SSE spec.
|
|||||||
|
|
||||||
Per the SSE spec the client (or `EventSource`) auto-reconnects after a
|
Per the SSE spec the client (or `EventSource`) auto-reconnects after a
|
||||||
network error, sending `Last-Event-ID`. The server resumes from the next
|
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>`.
|
entry after that ID (a range read of the stream's rows; under Redis this
|
||||||
|
was `XREAD … STREAMS file:events <id>`). With no `Last-Event-ID` the stream
|
||||||
|
starts at the head, resolved once at connect; `0` / `0-0` go through the
|
||||||
|
gap path and replay from the beginning.
|
||||||
|
|
||||||
**Gap handling.** If the requested ID has been trimmed out of the Redis
|
**Gap handling.** If the requested ID is older than the oldest retained
|
||||||
Stream by retention, the server emits one synthetic event before resuming
|
entry, the server emits one synthetic event before resuming
|
||||||
from the oldest available entry:
|
from the oldest available entry:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -271,9 +477,10 @@ react. Consumers that don't care ignore it.
|
|||||||
|
|
||||||
### Error handling
|
### Error handling
|
||||||
|
|
||||||
- **Redis unreachable on connect**: 503 with `Retry-After: 5`.
|
- **Store unavailable on connect**: 503 with `Retry-After: 5`.
|
||||||
- **Redis fails mid-stream**: close the SSE connection. Client reconnects
|
- **Store read fails mid-stream**: close the SSE connection. Client
|
||||||
with `Last-Event-ID`; the cycle resumes when Redis recovers.
|
reconnects with `Last-Event-ID`; the cycle resumes when meta-core is back
|
||||||
|
(a meta-core restart is the common case — SSE clients just reconnect).
|
||||||
- **Client disconnects**: handler goroutine exits cleanly; no per-client
|
- **Client disconnects**: handler goroutine exits cleanly; no per-client
|
||||||
state on meta-core.
|
state on meta-core.
|
||||||
|
|
||||||
@@ -307,7 +514,8 @@ inside-only until a concrete external consumer appears.
|
|||||||
### Implementation footprint
|
### Implementation footprint
|
||||||
|
|
||||||
The SSE handler itself is ~80 lines of Go in meta-core: one handler per
|
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
|
stream, each running a read-after-cursor loop (an `XREAD … BLOCK 30s` loop
|
||||||
|
in the Redis era) and writing
|
||||||
SSE-formatted lines to the response. Go's `net/http` natively supports
|
SSE-formatted lines to the response. Go's `net/http` natively supports
|
||||||
streaming responses with `http.Flusher.Flush()`. Standard SSE clients
|
streaming responses with `http.Flusher.Flush()`. Standard SSE clients
|
||||||
exist for every language we use: Node `eventsource`, Rust
|
exist for every language we use: Node `eventsource`, Rust
|
||||||
@@ -484,6 +692,10 @@ durability across a meta-core restart).
|
|||||||
|
|
||||||
### PR B — meta-sort writes via HTTP
|
### PR B — meta-sort writes via HTTP
|
||||||
|
|
||||||
|
> *History.* PR B moved writes onto the unsigned `/meta/{hash}` family.
|
||||||
|
> In v2.0.0 those routes answer `410 Gone`; writers sign claims and post
|
||||||
|
> them to `POST /api/claims` (see "Write contract — signed claims").
|
||||||
|
|
||||||
Swap `RedisClient` for an `HttpKVClient` implementation of the existing
|
Swap `RedisClient` for an `HttpKVClient` implementation of the existing
|
||||||
`IKVClient` interface in `packages/meta-sort-core`. All writes
|
`IKVClient` interface in `packages/meta-sort-core`. All writes
|
||||||
(`setMetadataFlat`, `setMetadataProperty`, batch updates) become HTTP
|
(`setMetadataFlat`, `setMetadataProperty`, batch updates) become HTTP
|
||||||
@@ -562,17 +774,22 @@ metacore-app produces a connection refused. The lock is real.
|
|||||||
| Reads from `file:*` direct? | Forbidden by network topology | Same lock, same reason |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Backing store for streams? | Redis Streams at the time; SQLite kv rows since 2026-10 | SSE wraps it; the 2026-10 swap proved it — no consumer changed |
|
||||||
| Consumer groups / horizontal scale? | Not supported via SSE | Not needed today; revisit if scale ever requires it |
|
| 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 |
|
| 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. |
|
| **Canonical write API path?** | **`POST /api/claims` (v2.0.0)** — signed claims only; every unsigned write route answers `410 Gone` | *History (v1.x):* the **`/meta/{hash}` family** was the canonical write surface. It had 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. |
|
| **SSE event-id contract?** | **Opaque to consumers** | It is `<ms>-<seq>` (the Redis Stream entry form, kept after the 2026-10 SQLite switch); 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. |
|
| **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. |
|
| **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. |
|
| **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
|
## Error envelope
|
||||||
|
|
||||||
|
(`410 Gone` from the removed v1.x write routes carries this envelope with a
|
||||||
|
message pointing to `POST /api/claims`. Per-claim outcomes of
|
||||||
|
`POST /api/claims` are statuses inside the response, not envelopes — see
|
||||||
|
"Per-claim statuses" above.)
|
||||||
|
|
||||||
Standard shape for all 4xx / 5xx responses from the metadata API:
|
Standard shape for all 4xx / 5xx responses from the metadata API:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -591,7 +808,11 @@ Stable `error` slugs the client should switch on:
|
|||||||
| `unknown_root` | 404 | no | The hashId / UUID doesn't exist. Caller probably has a stale reference. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `invalid_json` | 400 | no | `POST /api/claims` / `POST /api/merge` body is not claims JSON. Caller bug. |
|
||||||
|
| `too_many_claims` | 413 | no | More than 10 000 claims in one `POST /api/claims` or `POST /api/merge`. Split the batch. |
|
||||||
|
| `stale_version` | 409 | yes | `PUT /api/udl/record`: an equal or newer `ts` is stored for this signer, cid and key. Re-read the record, sign with a newer `ts`, retry. |
|
||||||
|
| `gone` | 410 | no | A removed unsigned write route. Body is `{"error":"gone","use":"POST /api/claims"}` (no `message` / `retryable`). |
|
||||||
|
| `storage_unavailable` | 503 | yes | Store transient (Redis at the time; the embedded SQLite store since 2026-10). Caller should retry with backoff. |
|
||||||
| `internal` | 500 | yes (cautiously) | Unhandled server-side error. Retry once; escalate if persistent. |
|
| `internal` | 500 | yes (cautiously) | Unhandled server-side error. Retry once; escalate if persistent. |
|
||||||
|
|
||||||
**Implemented.** Every 4xx/5xx response across `internal/api`,
|
**Implemented.** Every 4xx/5xx response across `internal/api`,
|
||||||
@@ -605,10 +826,9 @@ import cycle on the api package; consolidating it into a shared
|
|||||||
|
|
||||||
Mostly answered post-implementation:
|
Mostly answered post-implementation:
|
||||||
|
|
||||||
- **Deprecation timeline for `/api/metadata/{hashId}`.** Still open. The
|
- **Deprecation timeline for `/api/metadata/{hashId}`.** Answered by
|
||||||
editor's React UI continues to call it; the surface itself was untouched
|
v2.0.0: its `PUT`/`DELETE` (and `PUT …/property`) answer `410 Gone`
|
||||||
by this work. Worth a follow-up to consolidate the editor onto
|
like every unsigned write; `GET` stays as a merged read.
|
||||||
`/meta/{hash}` and delete the parallel routes.
|
|
||||||
|
|
||||||
- **Initial bootstrap flood on `/api/events/files`.** Resolved in
|
- **Initial bootstrap flood on `/api/events/files`.** Resolved in
|
||||||
practice: meta-fuse uses `initialCursor: '0-0'` to deliberately
|
practice: meta-fuse uses `initialCursor: '0-0'` to deliberately
|
||||||
@@ -628,8 +848,9 @@ Mostly answered post-implementation:
|
|||||||
- **Authentication between internal services.** The hash-lock OIDC
|
- **Authentication between internal services.** The hash-lock OIDC
|
||||||
perimeter handles external clients; internal service-to-service
|
perimeter handles external clients; internal service-to-service
|
||||||
trust is by network isolation (docker network for dev, equivalent
|
trust is by network isolation (docker network for dev, equivalent
|
||||||
isolation in prod deployments). Adding service-to-service auth is a
|
isolation in prod deployments). Since v2.0.0 metadata *writes* carry
|
||||||
separate concern.
|
their own authentication — the claim signature — so meta-core needs no
|
||||||
|
per-writer tokens; transport-level service auth remains out of scope.
|
||||||
- **Replacing the public HTTP API surface.** This proposal exercises
|
- **Replacing the public HTTP API surface.** This proposal exercises
|
||||||
what already exists; the only new endpoints are the two SSE streams.
|
what already exists; the only new endpoints are the two SSE streams.
|
||||||
- **Introducing new transport, broker, proxy, or sidecar.** Everything
|
- **Introducing new transport, broker, proxy, or sidecar.** Everything
|
||||||
@@ -678,6 +899,11 @@ What actually shipped vs. what this doc planned.
|
|||||||
|
|
||||||
### Write-path CID resolution (`storage.ResolveRoot`)
|
### Write-path CID resolution (`storage.ResolveRoot`)
|
||||||
|
|
||||||
|
> *History (v1.x).* In v2.0.0 a claim names a cid, never a rid, and the
|
||||||
|
> hidden rid comes from `cid_group` (a `cids/<other>` vote links two
|
||||||
|
> groups; the older rid survives). The dual-root failure below cannot
|
||||||
|
> recur: there is no write that addresses a root.
|
||||||
|
|
||||||
Not in the original plan but load-bearing.
|
Not in the original plan but load-bearing.
|
||||||
|
|
||||||
**The problem.** meta-sort's PATCH-via-HTTP migration (PR B) routed
|
**The problem.** meta-sort's PATCH-via-HTTP migration (PR B) routed
|
||||||
@@ -709,7 +935,9 @@ contract violation."
|
|||||||
One-shot fixer for already-stranded entries: walks every UUID's
|
One-shot fixer for already-stranded entries: walks every UUID's
|
||||||
`midhash256` field to build the orphan index, merges each stranded
|
`midhash256` field to build the orphan index, merges each stranded
|
||||||
`file:<midhash>/*` into the matching UUID via `MergeMetadataFlat`,
|
`file:<midhash>/*` into the matching UUID via `MergeMetadataFlat`,
|
||||||
deletes the stranded root, and re-points the alias.
|
deletes the stranded root, and re-points the alias. *Removed in
|
||||||
|
2026-10* with the SQLite switch, together with the other one-shot Redis
|
||||||
|
migrations; it had been run on watch.nsl.sh before the migration.
|
||||||
|
|
||||||
Verified on the dev stack: 48 stranded entries were reunited; the
|
Verified on the dev stack: 48 stranded entries were reunited; the
|
||||||
editor now shows every file with its full plugin metadata under one
|
editor now shows every file with its full plugin metadata under one
|
||||||
@@ -726,9 +954,10 @@ duplicated in each to avoid an import cycle on the `api` package.
|
|||||||
|
|
||||||
- **meta-dup SSE + read migration.** Not deployed, no test harness.
|
- **meta-dup SSE + read migration.** Not deployed, no test harness.
|
||||||
When revived, copy meta-fuse's pattern.
|
When revived, copy meta-fuse's pattern.
|
||||||
- **Editor `/api/metadata/{hashId}` consolidation.** The duplicate
|
- **Editor writes (resolved in v2.0.0).** `PUT /api/metadata/{hashId}`
|
||||||
editor-internal API surface is untouched; could be retired in a
|
answers `410`. meta-sort-editor's manual edits go through meta-sort's
|
||||||
follow-up by porting the editor UI to `/meta/{hash}`.
|
backend, which signs them as meta-sort (its own key) and posts them to
|
||||||
|
`POST /api/claims`. The editor's reads keep using `/api/metadata/{hashId}`.
|
||||||
- **Consolidated `internal/httperr` package** for the typed envelope
|
- **Consolidated `internal/httperr` package** for the typed envelope
|
||||||
helpers (currently duplicated 4× to avoid the import cycle). Code
|
helpers (currently duplicated 4× to avoid the import cycle). Code
|
||||||
hygiene; functionally a no-op.
|
hygiene; functionally a no-op.
|
||||||
|
|||||||
+353
-130
@@ -1,16 +1,24 @@
|
|||||||
# Meta-Core Architecture
|
# Meta-Core Architecture
|
||||||
|
|
||||||
|
> **meta-core v2.0.0 — the claims model.** Implementation basis:
|
||||||
|
> [`docs/implementation/metacore-claims-model.md`](../../../docs/implementation/metacore-claims-model.md)
|
||||||
|
> (meta-root). Where this file and that one disagree, that one wins.
|
||||||
|
|
||||||
## Document Purpose
|
## Document Purpose
|
||||||
|
|
||||||
This document describes the architectural design of **meta-core**, the
|
This document describes the architectural design of **meta-core**, the
|
||||||
standalone container that owns Redis, the leader-info file, the HTTP+SSE
|
standalone container that owns the metadata store (two embedded SQLite
|
||||||
|
databases since v2.0.0 — *truth* and *cache*; a single SQLite file in
|
||||||
|
v1.x; Redis until 2026-10), the leader-info file, the HTTP+SSE
|
||||||
metadata surface, and a WebDAV file server. It covers the leader gate,
|
metadata surface, and a WebDAV file server. It covers the leader gate,
|
||||||
storage data model, API design, the SSE event surface, and the integration
|
the claims data model and its write/apply pipeline, API design, the SSE
|
||||||
contract with other MetaMesh services.
|
event surface, and the integration contract with other MetaMesh services.
|
||||||
|
|
||||||
For the on-disk Redis layout, see
|
For the engine design (SQLite, group commit, measurements), see
|
||||||
|
[metacore-storage-engine.md](../../../docs/project-architecture/metacore-storage-engine.md);
|
||||||
|
for the on-disk layout (tables of both databases), see
|
||||||
[metadata-storage-structure.md](metadata-storage-structure.md). For the
|
[metadata-storage-structure.md](metadata-storage-structure.md). For the
|
||||||
external-access design (Redis lockdown, SSE wire contract, auth rules),
|
external-access design (store lockdown, SSE wire contract, auth rules),
|
||||||
see [api-mediated-access.md](api-mediated-access.md). For the UUID-rooted
|
see [api-mediated-access.md](api-mediated-access.md). For the UUID-rooted
|
||||||
storage rationale, see [uuid-rooted-metadata.md](uuid-rooted-metadata.md).
|
storage rationale, see [uuid-rooted-metadata.md](uuid-rooted-metadata.md).
|
||||||
|
|
||||||
@@ -23,7 +31,7 @@ view of file metadata:
|
|||||||
|
|
||||||
| Service | Language | Role |
|
| Service | Language | Role |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **meta-sort** | TypeScript | Watches folders, runs plugins, writes metadata via meta-core's HTTP API. |
|
| **meta-sort** | TypeScript | Watches folders, runs plugins; each plugin signs its own claims and posts them to meta-core. |
|
||||||
| **meta-fuse** | TypeScript / Rust | Serves the virtual filesystem from metadata. |
|
| **meta-fuse** | TypeScript / Rust | Serves the virtual filesystem from metadata. |
|
||||||
| **meta-stremio** | Python | Stremio addon for HLS streaming. |
|
| **meta-stremio** | Python | Stremio addon for HLS streaming. |
|
||||||
| **meta-dup** | TypeScript | Duplicate detection. |
|
| **meta-dup** | TypeScript | Duplicate detection. |
|
||||||
@@ -31,18 +39,30 @@ view of file metadata:
|
|||||||
|
|
||||||
meta-core sits below all of these as the single owner of:
|
meta-core sits below all of these as the single owner of:
|
||||||
|
|
||||||
1. **Redis** — the metadata store and event source.
|
1. **The metadata store** — two embedded SQLite databases: **truth**
|
||||||
|
(`/meta-core/db/meta-core.db`, backed up) holding the signed claims, and
|
||||||
|
**cache** (`/meta-core/db-cache/cache.db`, never backed up) holding every
|
||||||
|
index and derived row — a v1-layout `kv` table (the merged view as
|
||||||
|
`file:<rid>/<field>` rows, lookup, counters, event streams, service
|
||||||
|
scratch KV) with its field index and FTS, plus the `claim_by_pid` and
|
||||||
|
`local_file` tables. (v2.0.0 is implemented, uncommitted and not yet
|
||||||
|
deployed as of 2026-10-25.)
|
||||||
2. **The leader-info file** — the one place services look to discover meta-core's URLs.
|
2. **The leader-info file** — the one place services look to discover meta-core's URLs.
|
||||||
3. **The HTTP+SSE surface** — the only way external services touch the data plane.
|
3. **The HTTP+SSE surface** — the only way external services touch the data plane.
|
||||||
4. **A WebDAV server** — file access for plugins and other services.
|
4. **A WebDAV server** — file access for plugins and other services.
|
||||||
|
|
||||||
### Core design principle
|
### Core design principle
|
||||||
|
|
||||||
> External services never speak Redis. Every read, write, and event flows
|
> External services never touch the store. Every read, write, and event flows
|
||||||
> through meta-core's HTTP+SSE surface.
|
> through meta-core's HTTP+SSE surface.
|
||||||
|
|
||||||
This is the contract that PR D of [api-mediated-access.md](api-mediated-access.md)
|
This is the contract that PR D of [api-mediated-access.md](api-mediated-access.md)
|
||||||
landed and that the rest of this document assumes.
|
landed and that the rest of this document assumes. v2.0.0 adds a second:
|
||||||
|
|
||||||
|
> Every metadata value is a **signed vote** — `claim(uid, cid, key) → value`
|
||||||
|
> + `ts` + `sig`. The only write is `POST /api/claims`. meta-core verifies
|
||||||
|
> every claim and **never signs one**: it holds no writer key, and the
|
||||||
|
> signature is the write authentication.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -53,8 +73,8 @@ landed and that the rest of this document assumes.
|
|||||||
meta-core runs as a dedicated container (`metacore-app`). The dev compose
|
meta-core runs as a dedicated container (`metacore-app`). The dev compose
|
||||||
runs a single instance; the legacy multi-instance topology (sibling
|
runs a single instance; the legacy multi-instance topology (sibling
|
||||||
followers) was deprecated. The flock primitive is retained as the
|
followers) was deprecated. The flock primitive is retained as the
|
||||||
correctness anchor — it guarantees that at most one process owns Redis and
|
correctness anchor — it guarantees that at most one process writes the
|
||||||
the writable state under `/meta-core`.
|
database file and the writable state under `/meta-core`.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
@@ -62,17 +82,19 @@ the writable state under `/meta-core`.
|
|||||||
│ │
|
│ │
|
||||||
│ supervisord │
|
│ supervisord │
|
||||||
│ ├─ leader-election.sh (flock → write kv-leader.info → exec self) │
|
│ ├─ leader-election.sh (flock → write kv-leader.info → exec self) │
|
||||||
│ ├─ redis-server (AOF + RDB on /meta-core/db/redis) │
|
|
||||||
│ ├─ nginx (TLS termination, proxy_cache for /webdav) │
|
│ ├─ nginx (TLS termination, proxy_cache for /webdav) │
|
||||||
│ ├─ rclone rcd (mount daemon) │
|
│ ├─ rclone rcd (mount daemon) │
|
||||||
│ ├─ mount-watcher.sh (rclone mount lifecycle) │
|
│ ├─ mount-watcher.sh (rclone mount lifecycle) │
|
||||||
│ └─ meta-core (Go) ──► HTTP + SSE + WebDAV on :9000 │
|
│ └─ meta-core (Go) ──► HTTP + SSE + WebDAV on :9000 │
|
||||||
|
│ + embedded SQLite: truth (db/meta-core.db) │
|
||||||
|
│ and cache (cache/cache.db) │
|
||||||
└────────────────────────────────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌────────────────────────────┴────────────────────────────┐
|
┌────────────────────────────┴────────────────────────────┐
|
||||||
│ /meta-core (shared volume) │
|
│ /meta-core (shared volume) │
|
||||||
│ locks/kv-leader.{lock,info} │
|
│ locks/kv-leader.{lock,info} │
|
||||||
│ db/redis/ │
|
│ db/meta-core.db (+ -wal, -shm) truth, backed up │
|
||||||
|
│ cache/cache.db (+ -wal, -shm) cache, never backed │
|
||||||
│ services/*.json │
|
│ services/*.json │
|
||||||
│ mounts/{mounts.json, errors/} │
|
│ mounts/{mounts.json, errors/} │
|
||||||
│ watchers.json │
|
│ watchers.json │
|
||||||
@@ -152,7 +174,7 @@ Container startup
|
|||||||
▼
|
▼
|
||||||
┌──────────────────────────────┐
|
┌──────────────────────────────┐
|
||||||
│ supervisord starts: │
|
│ supervisord starts: │
|
||||||
│ redis → nginx → rclone → │
|
│ nginx → rclone → │
|
||||||
│ meta-core Go binary │
|
│ meta-core Go binary │
|
||||||
└──────────────────────────────┘
|
└──────────────────────────────┘
|
||||||
```
|
```
|
||||||
@@ -167,10 +189,11 @@ gate wins. Therefore:
|
|||||||
local hostname + IP + config and returns it on demand. There are no
|
local hostname + IP + config and returns it on demand. There are no
|
||||||
`OnBecomeLeader` / `OnBecomeFollower` / `OnLeaderLost` callbacks; those
|
`OnBecomeLeader` / `OnBecomeFollower` / `OnLeaderLost` callbacks; those
|
||||||
states do not exist inside the Go process.
|
states do not exist inside the Go process.
|
||||||
- `LeaderLockInfo.RedisUrl` is intentionally empty in the response from
|
- `LeaderLockInfo.RedisUrl` is always empty in the response from
|
||||||
`/urls` — see `internal/leader/election.go:82`. PR D of
|
`/urls` (kept for wire compatibility). PR D of
|
||||||
api-mediated-access.md removed direct Redis exposure; consumers route
|
api-mediated-access.md removed direct Redis exposure, and since 2026-10
|
||||||
metadata I/O through meta-core's HTTP API and SSE event streams.
|
there is no Redis at all; consumers route metadata I/O through
|
||||||
|
meta-core's HTTP API and SSE event streams.
|
||||||
|
|
||||||
### Lock-info file
|
### Lock-info file
|
||||||
|
|
||||||
@@ -192,75 +215,174 @@ Other services read this file, then call `GET /urls` for full discovery
|
|||||||
`metacore-app` is provisioned, its bash loop will acquire on next tick.
|
`metacore-app` is provisioned, its bash loop will acquire on next tick.
|
||||||
The dev compose runs a single instance; failover is "restart the
|
The dev compose runs a single instance; failover is "restart the
|
||||||
container."
|
container."
|
||||||
- **Redis crash inside the container** → supervisord restarts redis. The
|
- **The store is in-process** — there is no separate database process to
|
||||||
Go binary's connect loop retries up to 30 times (1s delay) at startup;
|
crash. A meta-core crash is a process restart; SQLite's WAL recovers the
|
||||||
at steady state, individual operations fail and clients retry.
|
last committed transaction on reopen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Schema-Version Gate
|
## Schema-Version Gate
|
||||||
|
|
||||||
Before serving any traffic, `cmd/meta-core/main.go:54-61` calls
|
Before serving any traffic, `cmd/meta-core/main.go` opens both databases
|
||||||
`storage.EnsureSchemaVersion`. The sentinel lives in Redis at
|
and calls `storage.EnsureSchemaVersion` (before the cache catch-up). In
|
||||||
`meta-core:schema-version` (`internal/storage/schema_version.go`). The
|
v2.0.0 the sentinel is the `schema-version = "3"` row of the truth
|
||||||
gate refuses to boot when the existing Redis layout predates the
|
database's `meta` table. The gate:
|
||||||
UUID-rooted schema, so legacy `file:midhash256:*` data can't leak into
|
|
||||||
the current code paths. In alpha there is no automated migration —
|
- **explicitly refuses a v1.x store** — a truth file holding the v1 `kv`
|
||||||
operators wipe with `pnpm run clean:all` and restart.
|
table (`meta-core:schema-version = "2"`), which the default path still
|
||||||
|
points at — with a message naming `tools/claims-migrate`;
|
||||||
|
- refuses a store with claims but no sentinel, or any other version;
|
||||||
|
- stamps a fresh store with `"3"`.
|
||||||
|
|
||||||
|
So no pre-claims data leaks into the current code paths. The meta-core
|
||||||
|
**service binary contains no migration code**: watch.nsl.sh is converted
|
||||||
|
once by the separate one-off tool `packages/meta-core/tools/claims-migrate`
|
||||||
|
(not built into the image; implementation basis §11); every other box and
|
||||||
|
dev stack starts empty (operators wipe with `pnpm run clean:all` and
|
||||||
|
restart).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
|
|
||||||
### Two concepts: metadata vs. file bytes
|
### The one concept: signed votes
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────────────────────────────────────────────────────────┐
|
claim(uid, cid, key) → value + ts + sig
|
||||||
│ METADATA (Redis) │
|
|
||||||
│ │
|
|
||||||
│ Root: opaque UUIDv7 (Crockford Base32; ULID layout, 26 chars) │
|
|
||||||
│ Storage: flat string keys │
|
|
||||||
│ file:<uuid>/<property> → string value │
|
|
||||||
│ file:<uuid>/cids/<bareCid> → "true" (key-set) │
|
|
||||||
│ file:<uuid>/duplicates → SET of paths │
|
|
||||||
│ │
|
|
||||||
│ No stored canonical_cid — derived by rank on read. │
|
|
||||||
│ │
|
|
||||||
│ Aliases (reverse index, one Redis STRING each): │
|
|
||||||
│ cid:<bareCid> → <uuid> │
|
|
||||||
│ │
|
|
||||||
│ Index: file:__index__ → SET of all UUIDs │
|
|
||||||
│ │
|
|
||||||
│ Mintable without a local file (remote / deleted entries OK). │
|
|
||||||
│ Resolvable by any registered CID in O(1). │
|
|
||||||
└────────────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
┌────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ FILE BYTES │
|
|
||||||
│ │
|
|
||||||
│ Location: /files volume (shared across containers). │
|
|
||||||
│ Access: path resolution via metadata, served via /webdav/*, │
|
|
||||||
│ /file/{cid} (reverse-lookup), or /data/{hash}. │
|
|
||||||
│ Path stored as filePath in the metadata, relative to /files. │
|
|
||||||
└────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Why UUID-rooted?
|
- `uid` — the signer (a secp256k1 public key, `z…` base58btc). One key per
|
||||||
|
writing container (each meta-sort plugin, each self-publishing feeder,
|
||||||
|
gateway core, meta-share, meta-search) and one per user.
|
||||||
|
- `cid` — the subject, a content CID; never a rid.
|
||||||
|
- `key` — the field (`METADATA_KEYS.md`); key-set members live inside the
|
||||||
|
key (`genres/Drama`, `cids/<cid>`).
|
||||||
|
- `value` — text (public), bytes (signed + encrypted envelope) or null
|
||||||
|
(retraction, kept as a tombstone).
|
||||||
|
- `ts` — milliseconds; it **is** the version and only increases per
|
||||||
|
`(uid, cid, key)`.
|
||||||
|
- `sig` — MMUR2: secp256k1 ECDSA (compact 64 bytes) over SHA-256 of
|
||||||
|
`"MMUR2\n" ‖ canonical CBOR [cid, uid, key, value, ts]`.
|
||||||
|
|
||||||
Roots were previously content-hash tokens (`file:midhash256:abc/...`),
|
The old shared record (`file:<rid>/<field>`) and the old User Data Layer
|
||||||
which privileged one hash algorithm. UUID-rooted storage treats every CID
|
store (`udl:rec:*`) both collapse into this shape. The store distinguishes
|
||||||
symmetrically as an alias, makes `GET /api/meta/<cid>` O(1) for any
|
only plaintext (signed) from ciphertext (signed + encrypted); "personal vs
|
||||||
registered CID, and lets new digest types be added without rewriting root
|
shared" is app logic.
|
||||||
keys. See [uuid-rooted-metadata.md](uuid-rooted-metadata.md) for the
|
|
||||||
design rationale and [metadata-storage-structure.md](metadata-storage-structure.md)
|
### Two databases
|
||||||
for the on-disk layout.
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ TRUTH /meta-core/db/meta-core.db (backed up; PKs only) │
|
||||||
|
│ │
|
||||||
|
│ principal(pid, uid) uid ↔ local surrogate pid │
|
||||||
|
│ claim(cid, key, pid → value, ts, sig, seq) │
|
||||||
|
│ the votes; PK (cid, key, pid) │
|
||||||
|
│ cid_group(cid → rid) hidden local record identity │
|
||||||
|
│ meta(name → value) schema-version, last_seq │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
│ apply loop (seq order)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CACHE /meta-core/db-cache/cache.db (never backed up; rebuildable)│
|
||||||
|
│ │
|
||||||
|
│ kv(k → v) the v1 layout, every row derived: │
|
||||||
|
│ file:<rid>/<field> the merged view every read uses │
|
||||||
|
│ cid:<cid> cid → rid lookup │
|
||||||
|
│ idx:roots/ idx:fts/ idx:count/ idx:schema/ roots, FTS rowid, │
|
||||||
|
│ counters (search totals, stats) │
|
||||||
|
│ ev:<stream>/…, evhwm:… /api/events/meta + /files backlog │
|
||||||
|
│ meta:applied_seq, meta:rebuilt_at apply-loop bookkeeping │
|
||||||
|
│ anything else /api/kv/value scratch KV │
|
||||||
|
│ kv_field_v (index), fts filter + free-text search │
|
||||||
|
│ claim_by_pid(pid, cid, key) "everything uid X wrote" (?as=, │
|
||||||
|
│ UDL facade, identity retraction) │
|
||||||
|
│ local_file(path → midhash, size, mtime) watcher facts, unsigned │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Disaster recovery is "delete the cache, replay truth". `META_CORE_DB_PATH`
|
||||||
|
names the truth file, `META_CORE_CACHE_PATH` the cache file. The cache keeps
|
||||||
|
the v1 `kv` layout so every v1 reader (record reads, field index, FTS,
|
||||||
|
counters, SSE + snapshot) runs unchanged on derived rows; `nsrc` is computed
|
||||||
|
by the merge (`POST /api/merge`), not stored. Full schema: implementation
|
||||||
|
basis §5.
|
||||||
|
|
||||||
|
### Records: cids, groups and the hidden rid
|
||||||
|
|
||||||
|
A vote never mentions a rid. A **rid** (UUIDv7, 26-char Crockford, as in
|
||||||
|
v1.x) is a local name for a group of cids known to be the same content,
|
||||||
|
held in `cid_group`:
|
||||||
|
|
||||||
|
- A new cid with no link gets its own group and a fresh rid.
|
||||||
|
- A `cids/<other>` vote on cid X links X's group and `<other>`'s group; the
|
||||||
|
group with the older rid survives, the losing rid gets a `del` event per
|
||||||
|
key and the survivor `set` events (no alias table). Retracting the link
|
||||||
|
does **not** split the group (phase 1); the record view synthesises
|
||||||
|
`cids/<cid> = "true"` for every cid of the group from `cid_group`.
|
||||||
|
- Reading record R = every vote on every cid of R's group, merged per key;
|
||||||
|
within a group one signer counts once per key.
|
||||||
|
- rids are never signed and never travel; consumers that hold them
|
||||||
|
(meta-sort, meta-fuse, meta-stremio) keep working because `cid_group`
|
||||||
|
lives in truth.
|
||||||
|
|
||||||
|
### Merge
|
||||||
|
|
||||||
|
Per `(rid, key)`, over the group's votes, each signer's newest vote counted
|
||||||
|
once (`internal/storage/merge.go`, shared by the apply loop, `?as=` and
|
||||||
|
`POST /api/merge`):
|
||||||
|
|
||||||
|
1. the viewer's own live text vote (`?as=<uid>`, applied at read time,
|
||||||
|
never stored);
|
||||||
|
2. retractions and ciphertext drop out — only text merges;
|
||||||
|
3. most distinct signers for the normalised value (**trim + case-fold
|
||||||
|
only**, no NFC) — `nsrc` is that count (computed, not stored);
|
||||||
|
4. tie → oldest `ts`;
|
||||||
|
5. tie → byte-wise smallest value.
|
||||||
|
|
||||||
|
The stored value is the winning vote's exact text. Key-sets need no special
|
||||||
|
code (each member is a key, present while one vote supports it). A
|
||||||
|
different kind of fact gets a different key, so a guesser (e.g.
|
||||||
|
filename-parser) never competes with a lookup on the same key.
|
||||||
|
|
||||||
|
### Box-local file facts
|
||||||
|
|
||||||
|
`filePath`, size, `mtimeNano` and midhash are **not claims** — meta-core
|
||||||
|
cannot sign, and the disk can always be rescanned. The file watcher and
|
||||||
|
`POST /api/files/register` write them into the cache's `local_file` table
|
||||||
|
(and the midhash into `cid_group`); they are joined into the record view
|
||||||
|
unsigned and never reach the claims API or the swarm. When several local
|
||||||
|
paths hold the same content, `filePath` is the lexicographically first one
|
||||||
|
(size and mtime from the same row); a signed `sizeByte` claim wins over the
|
||||||
|
on-disk size.
|
||||||
|
|
||||||
|
### File bytes
|
||||||
|
|
||||||
|
```
|
||||||
|
Location: /files volume (shared across containers).
|
||||||
|
Access: path resolution via local_file, served via /webdav/*,
|
||||||
|
/file/{cid} (reverse-lookup), or /data/{hash}.
|
||||||
|
Path: filePath (from local_file) in the record view, relative to /files.
|
||||||
|
```
|
||||||
|
|
||||||
|
### History: UUID-rooted flat keys (v1.x)
|
||||||
|
|
||||||
|
Until v2.0.0 a record was a set of flat keys under an opaque UUID root —
|
||||||
|
`file:<uuid>/<property>`, `file:<uuid>/cids/<bareCid>` key-sets,
|
||||||
|
`dup:<uuid>/<path>`, a reverse index `cid:<bareCid> → <uuid>` and a root
|
||||||
|
index `idx:roots/<uuid>`, all in one kv table. Roots had previously been
|
||||||
|
content-hash tokens (`file:midhash256:abc/...`); UUID roots treated every
|
||||||
|
CID symmetrically as an alias. The rid of v2.0.0 is that UUID, kept hidden
|
||||||
|
and local; migrated records kept their UUIDs. See
|
||||||
|
[uuid-rooted-metadata.md](uuid-rooted-metadata.md) (the *why*) and the
|
||||||
|
History note of [metadata-storage-structure.md](metadata-storage-structure.md).
|
||||||
|
|
||||||
### Path resolution
|
### Path resolution
|
||||||
|
|
||||||
`filePath` values stored in metadata are absolute paths under the `/files`
|
`filePath` values are absolute paths under the `/files` volume (e.g.
|
||||||
volume (e.g. `/files/watch/movies/Movie.2024.mkv`). The `/files` prefix is
|
`/files/watch/movies/Movie.2024.mkv`). The `/files` prefix is itself a
|
||||||
itself a per-host detail — moving the volume only requires re-mounting it
|
per-host detail — moving the volume only requires re-mounting it at the
|
||||||
at the same path inside the container.
|
same path inside the container.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -279,16 +401,16 @@ Bootstrap / discovery
|
|||||||
GET /neighbors (alias: /api/neighbors)
|
GET /neighbors (alias: /api/neighbors)
|
||||||
GET /api/stats
|
GET /api/stats
|
||||||
|
|
||||||
Metadata (primary surface for other services)
|
Claims (the only metadata write path)
|
||||||
|
POST /api/claims signed claim batch → per-claim status
|
||||||
|
POST /api/merge {claims, as?} → {fields, nsrc, rejected}; no write
|
||||||
|
GET /api/meta/{cid}/claims {rid, cids, claims} of the cid's group (?key=, ?uid=; 404 unknown)
|
||||||
|
GET /api/metadata/{rid}/claims same, addressed by rid
|
||||||
|
|
||||||
|
Metadata reads (merged view + local_file; optional ?as=<uid>)
|
||||||
GET /meta
|
GET /meta
|
||||||
GET /meta/{hash}
|
GET /meta/{hash}
|
||||||
PUT /meta/{hash}
|
|
||||||
PATCH /meta/{hash}
|
|
||||||
DELETE /meta/{hash}
|
|
||||||
GET /meta/{hash}/{key...}
|
GET /meta/{hash}/{key...}
|
||||||
PUT /meta/{hash}/{key...}
|
|
||||||
DELETE /meta/{hash}/{key...}
|
|
||||||
POST /meta/{hash}/_add/{key...}
|
|
||||||
|
|
||||||
CID-addressed (public, auth-bypassed at the perimeter)
|
CID-addressed (public, auth-bypassed at the perimeter)
|
||||||
GET /api/meta/{cid}
|
GET /api/meta/{cid}
|
||||||
@@ -298,26 +420,47 @@ CID-addressed (public, auth-bypassed at the perimeter)
|
|||||||
HEAD /data/{hash}
|
HEAD /data/{hash}
|
||||||
GET /data/{hash}/path
|
GET /data/{hash}/path
|
||||||
|
|
||||||
Editor / KV / schema / snapshot
|
Editor / KV / schema
|
||||||
GET /api/metadata/hash-ids
|
GET /api/metadata/hash-ids
|
||||||
GET /api/files/tuples
|
GET /api/files/tuples
|
||||||
|
POST /api/files/register (fills local_file + cid_group)
|
||||||
GET /api/metadata/list
|
GET /api/metadata/list
|
||||||
POST /api/metadata/search
|
POST /api/metadata/search
|
||||||
POST /api/metadata/batch
|
POST /api/metadata/clear (wipes both databases)
|
||||||
POST /api/metadata/clear
|
GET /api/metadata/{hashId} (hashId = rid)
|
||||||
{GET,PUT,DELETE} /api/metadata/{hashId}
|
GET /api/metadata/{hashId}/property
|
||||||
{GET,PUT} /api/metadata/{hashId}/property
|
GET /api/kv/{info,keys,tree,search,find} (read-only browser over the cache kv table)
|
||||||
GET /api/kv/{info,keys,tree,search,find,value}
|
{GET,PUT,DELETE} /api/kv/value (service scratch keys only; reserved prefixes refused)
|
||||||
PUT /api/kv/value
|
GET /api/kv/key/{key...} (read-only)
|
||||||
DELETE /api/kv/value
|
|
||||||
GET /api/kv/key/{key...}
|
|
||||||
GET /api/schema
|
GET /api/schema
|
||||||
POST /api/schema/rescan
|
POST /api/schema/rescan
|
||||||
GET /api/snapshot/export
|
|
||||||
POST /api/snapshot/import
|
|
||||||
POST /api/snapshot/wipe
|
|
||||||
|
|
||||||
SSE event streams (HTTP-mirror of Redis Streams)
|
User Data Layer (facade over claims; records are MMUR2)
|
||||||
|
{GET,PUT} /api/udl/record
|
||||||
|
PUT /api/udl/records
|
||||||
|
GET /api/udl/user/{uid}/{key/{key},cid/{cid},all,count}
|
||||||
|
GET /api/udl/cid/{cid}/{users,aggregate}
|
||||||
|
GET /api/udl/users/stats (every signer with live claims, plugin keys included)
|
||||||
|
|
||||||
|
Account-key identity service (kept in v2.0.0; removal is a separate ticket)
|
||||||
|
{GET,DELETE} /api/identity (DELETE signs + stores a retraction of every live claim first)
|
||||||
|
GET /api/identity/{accounts,aead-key}
|
||||||
|
POST /api/identity/{challenge,generate,import,reveal,sign,sign-batch}
|
||||||
|
(sign/sign-batch `format`: compact | der; default
|
||||||
|
compact for "MMUR2\n" payloads, DER otherwise)
|
||||||
|
|
||||||
|
Removed in v2.0.0 — answer 410 Gone, pointing to POST /api/claims
|
||||||
|
PUT/PATCH/DELETE /meta/{hash}
|
||||||
|
PUT/DELETE /meta/{hash}/{key...}
|
||||||
|
POST /meta/{hash}/_add/{key...}
|
||||||
|
PUT/DELETE /api/metadata/{hashId}
|
||||||
|
PUT /api/metadata/{hashId}/property
|
||||||
|
POST /api/metadata/batch
|
||||||
|
|
||||||
|
Removed in v2.0.0 — no snapshot format
|
||||||
|
GET /api/snapshot/export, POST /api/snapshot/{import,wipe}
|
||||||
|
|
||||||
|
SSE event streams (HTTP-mirror of the store's event streams)
|
||||||
GET /api/events/files
|
GET /api/events/files
|
||||||
GET /api/events/meta
|
GET /api/events/meta
|
||||||
|
|
||||||
@@ -337,11 +480,10 @@ Watchers
|
|||||||
POST /api/watchers/{scan-all,reset-all}
|
POST /api/watchers/{scan-all,reset-all}
|
||||||
(deprecated: /api/scan/trigger, /api/scan/status — see watcher/handlers.go:32)
|
(deprecated: /api/scan/trigger, /api/scan/status — see watcher/handlers.go:32)
|
||||||
|
|
||||||
Admin
|
(The one-shot Redis-era admin migrations — `/api/admin/migrate-dual-roots`,
|
||||||
POST /api/admin/migrate-dual-roots
|
`migrate-domain-screen`, `migrate-user-cids`, `sweep-literature-echoes` —
|
||||||
POST /api/admin/sweep-literature-echoes (dry-run unless ?apply=true;
|
were removed with the 2026-10 SQLite switch; they had been run once on
|
||||||
strips query echoes off domain=literature fileType=card records —
|
watch.nsl.sh beforehand.)
|
||||||
see storage/literature_echo_sweep.go)
|
|
||||||
|
|
||||||
WebDAV
|
WebDAV
|
||||||
/webdav/... (GET, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND)
|
/webdav/... (GET, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND)
|
||||||
@@ -364,17 +506,28 @@ peers and unauthenticated tooling can reach them):
|
|||||||
- (`/api/events/files`, `/api/events/meta` are also auth-bypassed but
|
- (`/api/events/files`, `/api/events/meta` are also auth-bypassed but
|
||||||
**should not** be exposed publicly via Caddy; they're inside-only.)
|
**should not** be exposed publicly via Caddy; they're inside-only.)
|
||||||
|
|
||||||
|
Since v2.0.0 `/meta/*` serves reads only — its write verbs answer `410
|
||||||
|
Gone` — so the bypass no longer exposes an unsigned write. Writes go to
|
||||||
|
`POST /api/claims`, where the claim signature (not the perimeter) is the
|
||||||
|
write authentication. `POST /api/claims`, `POST /api/merge` and
|
||||||
|
`GET /api/meta/{cid}/claims` are container-to-container routes, not routed
|
||||||
|
through Caddy, like the v1.x write routes were.
|
||||||
|
|
||||||
See `docs/api-mediated-access.md` "Auth" for the rationale.
|
See `docs/api-mediated-access.md` "Auth" for the rationale.
|
||||||
|
|
||||||
### SSE event streams — wire contract
|
### SSE event streams — wire contract
|
||||||
|
|
||||||
`/api/events/files` and `/api/events/meta` are SSE mirrors of the
|
`/api/events/files` and `/api/events/meta` are SSE mirrors of the
|
||||||
`file:events` and `meta:events` Redis Streams. The contract
|
`file:events` and `meta:events` streams, stored as `ev:<stream>/…` rows of
|
||||||
|
the kv table (`internal/kv/stream.go`). The contract
|
||||||
(`internal/api/sse_events.go`):
|
(`internal/api/sse_events.go`):
|
||||||
|
|
||||||
- One SSE event per Redis Stream entry.
|
- One SSE event per stream entry.
|
||||||
- `id:` is the opaque `<ms>-<seq>` Redis Stream entry ID — clients echo
|
- `id:` is the opaque `<ms>-<seq>` entry ID (the Redis Stream form, kept so
|
||||||
it back via `Last-Event-ID` on reconnect.
|
cursors persisted before the 2026-10 switch still compare) — clients echo
|
||||||
|
it back via `Last-Event-ID` on reconnect. Without `Last-Event-ID` the
|
||||||
|
stream starts at the head (resolved once at connect); `0` / `0-0` replay
|
||||||
|
from the beginning.
|
||||||
- `event:` is the `type` field of the underlying entry.
|
- `event:` is the `type` field of the underlying entry.
|
||||||
- `data:` is the JSON of the rest of the entry.
|
- `data:` is the JSON of the rest of the entry.
|
||||||
- Heartbeats: SSE comment `:keep-alive\n\n` every 30s of silence.
|
- Heartbeats: SSE comment `:keep-alive\n\n` every 30s of silence.
|
||||||
@@ -383,9 +536,46 @@ See `docs/api-mediated-access.md` "Auth" for the rationale.
|
|||||||
`event: gap` payload before resuming from the oldest available entry.
|
`event: gap` payload before resuming from the oldest available entry.
|
||||||
|
|
||||||
External services use SSE with `Last-Event-ID` as the resume primitive.
|
External services use SSE with `Last-Event-ID` as the resume primitive.
|
||||||
A Redis consumer-group (XREADGROUP / XACK) exists *internally* — the
|
Since v2.0.0 `meta:events` describes changes of the **merged** view, same
|
||||||
`MetaPublisher` uses one to drain keyspace notifications — but it is not
|
`{type: set|del, key, ts}` shape: the cache engine appends an
|
||||||
the external contract.
|
`ev:meta:events/…` row for every change of a merged `file:` row, in the same
|
||||||
|
cache transaction as the change (both streams are v1 stream rows of the
|
||||||
|
cache `kv` table). A cache rebuild
|
||||||
|
emits no per-key events — consumers get `event: gap` and take a snapshot;
|
||||||
|
event IDs stay time-based and keep rising. *History:* v1.x appended them in
|
||||||
|
the same transaction as the `file:` key change; the Redis-era
|
||||||
|
keyspace-notification hop through `MetaPublisher` is long gone.
|
||||||
|
|
||||||
|
### Write path and apply loop
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/claims ──► per claim, before the writer:
|
||||||
|
0. validate (required fields, reserved keys, ts range) → else bad_request
|
||||||
|
1. same ts + sig as stored? → unchanged (no verify)
|
||||||
|
2. same value as stored (any ts)? → unchanged; stored row + older ts kept
|
||||||
|
3. stored ts ≥ incoming ts? → stale
|
||||||
|
4. verify MMUR2 signature → else bad_signature
|
||||||
|
──► accepted claims, one group-committed truth transaction:
|
||||||
|
5. re-check 1–3 under the writer
|
||||||
|
6. uid → pid (principal; insert on first sight)
|
||||||
|
7. upsert claim (tombstone if null), seq = ++last_seq
|
||||||
|
8. cid_group: mint rid for unknown cid; text cids/* links groups
|
||||||
|
(a retraction never splits)
|
||||||
|
9. commit (last_seq in truth meta); answer once the cache applied it
|
||||||
|
──► apply loop, in seq order, into the cache:
|
||||||
|
sync cid:/cids/<cid>/idx:roots rows → claim_by_pid
|
||||||
|
→ re-merge (rid, key) from truth PK range reads
|
||||||
|
→ set/delete file:<rid>/<key> (+ local_file for filePath/sizeByte/mtimeNano)
|
||||||
|
⇒ meta:events row + counter delta in the same tx
|
||||||
|
→ FTS row on a haystack change → meta:applied_seq
|
||||||
|
```
|
||||||
|
|
||||||
|
The verifier lives in the write path (secp256k1 from `internal/identity`);
|
||||||
|
meta-core holds **no** writer key and never signs a claim. Response: one
|
||||||
|
status per claim — `accepted` · `unchanged` · `stale` · `bad_signature` ·
|
||||||
|
`bad_request`. Request/response shapes and the 410 contract for removed
|
||||||
|
routes: [api-mediated-access.md](api-mediated-access.md) "Write contract —
|
||||||
|
signed claims".
|
||||||
|
|
||||||
### WebDAV URLs
|
### WebDAV URLs
|
||||||
|
|
||||||
@@ -415,7 +605,8 @@ WebDAV caching is handled by nginx's `proxy_cache` (see
|
|||||||
| Single binary | Static compilation |
|
| Single binary | Static compilation |
|
||||||
| Low resource | ~10-20 MB resident |
|
| Low resource | ~10-20 MB resident |
|
||||||
| HTTP server | stdlib `net/http` + gorilla/mux |
|
| HTTP server | stdlib `net/http` + gorilla/mux |
|
||||||
| Redis client | go-redis |
|
| Metadata store | embedded SQLite via `modernc.org/sqlite` (pure Go, CGO off) — truth + cache databases |
|
||||||
|
| Claim verification | secp256k1 ECDSA (`internal/identity`) |
|
||||||
| WebDAV | `golang.org/x/net/webdav` |
|
| WebDAV | `golang.org/x/net/webdav` |
|
||||||
|
|
||||||
### Repo layout
|
### Repo layout
|
||||||
@@ -428,12 +619,12 @@ packages/meta-core/
|
|||||||
│ ├── cid/ CID parsing + algorithm ranking
|
│ ├── cid/ CID parsing + algorithm ranking
|
||||||
│ ├── config/ env-driven configuration
|
│ ├── config/ env-driven configuration
|
||||||
│ ├── discovery/ service registry + dead-service cleaner
|
│ ├── discovery/ service registry + dead-service cleaner
|
||||||
│ ├── events/ meta:events stream publisher
|
│ ├── identity/ secp256k1 (claim verification; account-key service)
|
||||||
|
│ ├── kv/ SQLite engine (single writer, group commit) + event streams
|
||||||
│ ├── leader/ LeaderInfoProvider (no election logic)
|
│ ├── leader/ LeaderInfoProvider (no election logic)
|
||||||
│ ├── mounts/ rclone mount manager + handlers + stats
|
│ ├── mounts/ rclone mount manager + handlers + stats
|
||||||
│ ├── schema/ live per-field schema indexer
|
│ ├── schema/ per-field schema from transactional counters
|
||||||
│ ├── snapshot/ snapshot export / import / wipe
|
│ ├── storage/ claims write path, apply loop, merge, cid_group, search, schema gate
|
||||||
│ ├── storage/ Redis wrapper + UUID + CID reverse index + schema gate
|
|
||||||
│ ├── watcher/ scanner + dispatcher + midhash + state
|
│ ├── watcher/ scanner + dispatcher + midhash + state
|
||||||
│ ├── watchers/ polling-based watcher configs
|
│ ├── watchers/ polling-based watcher configs
|
||||||
│ └── webdav/ /webdav/* handler
|
│ └── webdav/ /webdav/* handler
|
||||||
@@ -454,21 +645,29 @@ packages/meta-core/
|
|||||||
```
|
```
|
||||||
1. Load configuration from environment
|
1. Load configuration from environment
|
||||||
2. Build LeaderInfoProvider (no election; bash gate already won)
|
2. Build LeaderInfoProvider (no election; bash gate already won)
|
||||||
3. Connect to local Redis (retry up to 30× / 1s)
|
3. Create the cache folder; open the truth database (META_CORE_DB_PATH or
|
||||||
4. EnsureSchemaVersion (abort if Redis layout is stale)
|
/meta-core/db/meta-core.db) and the cache (META_CORE_CACHE_PATH or
|
||||||
5. Start service discovery + dead-service cleaner
|
/meta-core/db-cache/cache.db) (storage.Connect)
|
||||||
6. NewServer + Server.Start (boots watcher, mounts, WebDAV, API)
|
4. EnsureSchemaVersion (abort on a v1 kv store or an unknown layout)
|
||||||
7. On first connection: start MetaPublisher + schema Indexer,
|
5. storage.Start — cache catch-up (meta:applied_seq ≥ last_seq → nothing; behind →
|
||||||
republish existing metadata onto meta:events
|
replay claims with seq > applied_seq, or a full
|
||||||
8. Wait for SIGINT/SIGTERM
|
rebuild if a cids/* link is among them; no
|
||||||
9. Shutdown in reverse order
|
cache → full rebuild), then the apply loop
|
||||||
|
6. Derived counters ready
|
||||||
|
7. Start service discovery + dead-service cleaner
|
||||||
|
8. NewServer + Server.Start (boots watcher, mounts, WebDAV, API)
|
||||||
|
9. Wait for SIGINT/SIGTERM
|
||||||
|
10. Shutdown in reverse order
|
||||||
|
|
||||||
|
(v1.x ran `EnsureCounters` and a background FTS rebuild here; in v2.0.0
|
||||||
|
counters and FTS are cache rows maintained with every merged-row change.)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Process management
|
### Process management
|
||||||
|
|
||||||
Redis is not spawned by the Go binary; supervisord starts it before
|
There is no database process: the store is embedded in the Go binary
|
||||||
meta-core (`docker/supervisord.conf`). The Go binary connects to
|
(`docker/supervisord.conf` runs meta-core, nginx and rclone). Until
|
||||||
`localhost:6379` and retries until Redis is reachable.
|
2026-10 supervisord also ran `redis-server` on `localhost:6379`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -476,15 +675,31 @@ meta-core (`docker/supervisord.conf`). The Go binary connects to
|
|||||||
|
|
||||||
### Container restart
|
### Container restart
|
||||||
|
|
||||||
Supervisord restarts crashed processes (meta-core, redis, nginx, rclone)
|
Supervisord restarts crashed processes (meta-core, nginx, rclone)
|
||||||
individually. The bash flock is held by supervisord's process tree, so
|
individually. The bash flock is held by supervisord's process tree, so
|
||||||
internal crashes don't trigger re-election.
|
internal crashes don't trigger re-election.
|
||||||
|
|
||||||
### Redis crash
|
### Store durability
|
||||||
|
|
||||||
Supervisord restarts redis. The Go binary's connect retry handles the
|
Both SQLite files run in WAL mode; truth with `synchronous=NORMAL` (every
|
||||||
window. Persistence is RDB + AOF on the `/meta-core` volume, so no data is
|
committed transaction survives a meta-core process crash), the cache with
|
||||||
lost across restarts.
|
`synchronous=OFF` (it is rebuildable). A claim batch
|
||||||
|
commits to truth in one transaction; the apply loop then commits the
|
||||||
|
derived cache changes. **A crash between the two commits leaves the cache
|
||||||
|
behind, never wrong**: at boot, if the cache's `applied_seq` is below
|
||||||
|
truth's `last_seq`, meta-core scans truth's `claim` table for
|
||||||
|
`seq > applied_seq` (a full scan — there is no seq index; it only happens
|
||||||
|
after an unclean shutdown) and applies them — or rebuilds the cache in full
|
||||||
|
if one of them is a `cids/*` link.
|
||||||
|
|
||||||
|
### Cache loss
|
||||||
|
|
||||||
|
The cache is never backed up and is fully derived (except `local_file`,
|
||||||
|
which a rescan of `/files` refills, and the service scratch keys, whose
|
||||||
|
loss costs a refetch). A missing cache folder (no `meta:applied_seq`) means
|
||||||
|
meta-core rebuilds every derived row from truth and stamps
|
||||||
|
`meta:rebuilt_at`. A rebuild emits no per-key events;
|
||||||
|
SSE consumers get `event: gap` and re-snapshot.
|
||||||
|
|
||||||
### Container crash
|
### Container crash
|
||||||
|
|
||||||
@@ -495,8 +710,9 @@ the container."
|
|||||||
### Schema mismatch
|
### Schema mismatch
|
||||||
|
|
||||||
`EnsureSchemaVersion` refuses to boot. Operators wipe with
|
`EnsureSchemaVersion` refuses to boot. Operators wipe with
|
||||||
`pnpm run clean:all` (or equivalent) and restart. This is an alpha-stage
|
`pnpm run clean:all` (or equivalent) and restart, or — once, on
|
||||||
hard-fail; there is no automated migration path.
|
watch.nsl.sh — convert a v1.x store with `tools/claims-migrate`. This is an
|
||||||
|
alpha-stage hard-fail; the service never migrates automatically.
|
||||||
|
|
||||||
### Shared volume unavailable
|
### Shared volume unavailable
|
||||||
|
|
||||||
@@ -517,7 +733,11 @@ via `/health`; metadata I/O and SSE event streams stop.
|
|||||||
| `SERVICE_VERSION` | `1.0.0` | Reported in `/status`. |
|
| `SERVICE_VERSION` | `1.0.0` | Reported in `/status`. |
|
||||||
| `API_PORT` | `8180` | External port baked into `baseUrl`. |
|
| `API_PORT` | `8180` | External port baked into `baseUrl`. |
|
||||||
| `BASE_URL` | _empty_ | Overrides constructed `baseUrl` (HTTPS perimeter). |
|
| `BASE_URL` | _empty_ | Overrides constructed `baseUrl` (HTTPS perimeter). |
|
||||||
| `REDIS_PORT` | `6379` | Local Redis port. |
|
| `META_CORE_DB_PATH` | `<META_CORE_PATH>/db/meta-core.db` | Truth database file (backed up). Dev stacks set `/meta-core-db/meta-core.db` (named volume — SQLite WAL is unsafe on the 9p `/meta-core` bind of Windows dev boxes). |
|
||||||
|
| `META_CORE_CACHE_PATH` | `/meta-core/db-cache/cache.db` | Cache database file (never backed up; rebuilt from truth when missing). Dev stacks keep it on a named volume too; store apps put the cache folder in Maison's `backup.exclude`. |
|
||||||
|
| `META_CORE_DB_CACHE_MB` | `64` | Page cache of the writer connection. |
|
||||||
|
| `META_CORE_DB_READER_CACHE_MB` | `16` | Page cache per reader connection. |
|
||||||
|
| `META_CORE_DB_READERS` | `8` | Reader connection pool size. |
|
||||||
| `META_CORE_HTTP_PORT` | `9000` | Go HTTP+SSE+WebDAV port. |
|
| `META_CORE_HTTP_PORT` | `9000` | Go HTTP+SSE+WebDAV port. |
|
||||||
| `META_CORE_HTTP_HOST` | `0.0.0.0` | HTTP bind address. |
|
| `META_CORE_HTTP_HOST` | `0.0.0.0` | HTTP bind address. |
|
||||||
| `HEALTH_CHECK_INTERVAL_MS` | `5000` | Internal health-loop cadence. |
|
| `HEALTH_CHECK_INTERVAL_MS` | `5000` | Internal health-loop cadence. |
|
||||||
@@ -536,7 +756,8 @@ via `/health`; metadata I/O and SSE event streams stop.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `locks/kv-leader.lock` | Flock target. |
|
| `locks/kv-leader.lock` | Flock target. |
|
||||||
| `locks/kv-leader.info` | Leader API URL (plaintext). |
|
| `locks/kv-leader.info` | Leader API URL (plaintext). |
|
||||||
| `db/redis/{dump.rdb, appendonly.aof}` | Redis persistence. |
|
| `db/meta-core.db` (+ `-wal`, `-shm`) | Truth database (claims, principals, `cid_group`). Backed up. Replaced `db/redis/` in 2026-10; holds the claims schema since v2.0.0. |
|
||||||
|
| `db-cache/cache.db` (+ `-wal`, `-shm`) | Cache database (v1-layout `kv` table: merged view, lookup, counters, events, service scratch keys; field index, FTS, `claim_by_pid`, `local_file`). Never backed up. |
|
||||||
| `services/*.json` | Service registry. |
|
| `services/*.json` | Service registry. |
|
||||||
| `mounts/mounts.json` | rclone mount configuration. |
|
| `mounts/mounts.json` | rclone mount configuration. |
|
||||||
| `mounts/errors/{id}.error` | Mount error logs. |
|
| `mounts/errors/{id}.error` | Mount error logs. |
|
||||||
@@ -550,7 +771,9 @@ via `/health`; metadata I/O and SSE event streams stop.
|
|||||||
meta-core consolidates leader gating, metadata storage, event mirroring,
|
meta-core consolidates leader gating, metadata storage, event mirroring,
|
||||||
and file access for MetaMesh into one container with a typed HTTP+SSE
|
and file access for MetaMesh into one container with a typed HTTP+SSE
|
||||||
surface. The bash flock gate guarantees single-leader semantics without
|
surface. The bash flock gate guarantees single-leader semantics without
|
||||||
an external consensus system. UUID-rooted storage with CID aliasing
|
an external consensus system. Metadata is a table of signed votes in a
|
||||||
treats all content hashes symmetrically and makes any-CID resolution
|
backed-up truth database, merged by an apply loop into a rebuildable cache
|
||||||
O(1). API-mediated access ensures no other service ever needs to speak
|
that serves every read; meta-core verifies every claim and signs none.
|
||||||
Redis.
|
CID groups behind a hidden local rid treat all content hashes
|
||||||
|
symmetrically. API-mediated access ensures no other service ever touches
|
||||||
|
the store.
|
||||||
|
|||||||
+260
-231
@@ -1,180 +1,199 @@
|
|||||||
# Redis Metadata Storage Structure
|
# Metadata Storage Structure
|
||||||
|
|
||||||
How meta-core lays out file metadata in Redis. This document covers the
|
How meta-core lays out metadata on disk since **v2.0.0 (claims model)**: the
|
||||||
on-disk shape (UUID-rooted flat string keys, CID reverse index,
|
two SQLite databases, their tables, how a record is assembled from signed
|
||||||
per-root sets) and the schema-version gate. It does **not** enumerate
|
votes, and the schema-version gate. Implementation basis:
|
||||||
field semantics — that's the job of the repo-root
|
[`docs/implementation/metacore-claims-model.md`](../../../docs/implementation/metacore-claims-model.md).
|
||||||
[`METADATA_KEYS.md`](../../../METADATA_KEYS.md), the authoritative key
|
This document does **not** enumerate field semantics — that is the job of the
|
||||||
registry for everyone who writes to or reads from metadata.
|
repo-root [`METADATA_KEYS.md`](../../../METADATA_KEYS.md), the authoritative
|
||||||
|
key registry for everyone who writes or reads metadata.
|
||||||
|
|
||||||
For the design rationale (why UUIDs instead of content hashes, why a
|
Related: why rids are UUIDs and hidden —
|
||||||
reverse index), see [`uuid-rooted-metadata.md`](uuid-rooted-metadata.md).
|
[`uuid-rooted-metadata.md`](uuid-rooted-metadata.md); why the engine is
|
||||||
For the external-access contract (HTTP-only, SSE for events), see
|
shaped this way, its guarantees and measurements —
|
||||||
|
[`metacore-storage-engine.md`](../../../docs/project-architecture/metacore-storage-engine.md);
|
||||||
|
the external-access contract (HTTP-only, SSE for events) —
|
||||||
[`api-mediated-access.md`](api-mediated-access.md).
|
[`api-mediated-access.md`](api-mediated-access.md).
|
||||||
|
|
||||||
|
> **History.** Until 2026-10 metadata lived in Redis; v1.1.0–v1.2.x kept the
|
||||||
|
> same flat keys (`file:<rid>/<field>`, `cid:<cid>`, `idx:*`, `dup:*`,
|
||||||
|
> `udl:*`, `ev:*`) in one SQLite string→string `kv` table. v2.0.0 replaced
|
||||||
|
> that keyspace entirely; the old layout is summarised at the end
|
||||||
|
> ([Previous layout](#previous-layout-v11v12-kv-table)).
|
||||||
|
|
||||||
## Quick summary
|
## Quick summary
|
||||||
|
|
||||||
- **Roots are UUIDv7** encoded as 26-char Crockford Base32 (ULID layout),
|
- **Every metadata value is a signed claim**: `claim(uid, cid, key) → value`
|
||||||
not content hashes. Examples in this doc use the placeholder
|
+ `ts` + `sig` (MMUR2, secp256k1). One row per signer per field. Writers
|
||||||
`01JKR8XW5T4QMV7AHNJ2DEFGHK`.
|
sign; meta-core never signs.
|
||||||
- **Properties are flat Redis STRING keys**, one per leaf:
|
- **Two databases.** *Truth* `/meta-core/db/meta-core.db`
|
||||||
`file:<uuid>/<property/path>` → string value. *Not* Redis Hashes.
|
(`META_CORE_DB_PATH`, backed up, primary keys only): `principal`, `claim`,
|
||||||
- **CIDs are reverse-index aliases**: `cid:<bareCid>` → `<uuid>`
|
`cid_group`, `meta`. *Cache* `/meta-core/db-cache/cache.db`
|
||||||
(a Redis STRING per known CID; no algorithm prefix — the multicodec
|
(`META_CORE_CACHE_PATH`, never backed up, rebuildable): the v1 `kv` table
|
||||||
already carries it).
|
(merged view as `file:<rid>/<field>` rows, lookup, counters, event streams,
|
||||||
- **Per-root sets**: `file:<uuid>/cids/<bareCid>` (a *key-set*: one STRING
|
bookkeeping, service scratch keys) with its `kv_field_v` index and `fts`,
|
||||||
`"true"` per known CID, midhash included), `file:<uuid>/duplicates`
|
plus two real tables, `claim_by_pid` and `local_file`.
|
||||||
(alternate paths with the same content), plus `file:__index__` (every UUID).
|
- **Schema-free.** A field is a `key` string in a row; a new field never
|
||||||
There is **no stored `canonical_cid`** — it is derived by rank on read.
|
needs DDL.
|
||||||
- **A schema-version sentinel** (`meta-core:schema-version`) refuses to
|
- **Records are cid groups.** A vote names a cid, never a rid. `cid_group`
|
||||||
boot against legacy data.
|
maps each cid to a **rid** — a hidden, locally minted UUIDv7 (26-char
|
||||||
|
Crockford) naming the group. Examples use `01JKR8XW5T4QMV7AHNJ2DEFGHK`.
|
||||||
|
- **What readers get is the merged view**: one precomputed value per
|
||||||
|
`(rid, key)`, stored as the cache row `file:<rid>/<key>`. `nsrc` (how many
|
||||||
|
signers support it) is computed by the merge and returned by
|
||||||
|
`POST /api/merge`, not stored. `canonical_cid` is still **derived on read**
|
||||||
|
by rank, never stored.
|
||||||
|
- **Watcher facts are not claims**: `filePath`, size, `mtimeNano`, midhash
|
||||||
|
live in `local_file` and are joined into the record view unsigned.
|
||||||
|
- **A schema-version sentinel** (`schema-version = "3"` in truth `meta`).
|
||||||
|
meta-core explicitly refuses a truth file holding the v1 `kv` table, and any
|
||||||
|
store with claims but no sentinel or another version.
|
||||||
|
|
||||||
Implementation lives in `internal/storage/client.go`,
|
## Truth database — `/meta-core/db/meta-core.db`
|
||||||
`internal/storage/cid_resolution.go`, `internal/storage/uuid.go`, and
|
|
||||||
`internal/storage/schema_version.go`.
|
|
||||||
|
|
||||||
## Key structure
|
Backed up; only primary keys (plus `principal.uid`'s implicit UNIQUE index).
|
||||||
|
|
||||||
### Roots
|
| Table | Columns (PK first) | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `principal` | `pid` INTEGER PK, `uid` TEXT UNIQUE | `uid` = `"z" + base58btc(33-byte compressed pubkey)`. `pid` is a local surrogate: never signed, never on the wire |
|
||||||
|
| `claim` | `(cid, key, pid)` PK, `value` ANY, `ts`, `sig`, `seq` | `STRICT, WITHOUT ROWID`. `value`: TEXT = plaintext, BLOB = ciphertext envelope, NULL = retraction (tombstone, kept). `ts` (ms) is the version. `seq` = global change counter, not indexed |
|
||||||
|
| `cid_group` | `cid` PK, `rid` | the cid → rid map: the record identity layer |
|
||||||
|
| `meta` | `name` PK, `value` | `schema-version = "3"`, `last_seq` |
|
||||||
|
|
||||||
|
`claim` is written **only** by `POST /api/claims`. A claim replaces the
|
||||||
|
stored row for its `(cid, key, pid)` only if its `ts` is strictly greater;
|
||||||
|
identical `(ts, sig)` is `unchanged`; the **same value** from the same signer
|
||||||
|
(any `ts`) is also `unchanged` and keeps the stored row with its older `ts`;
|
||||||
|
otherwise older or equal is `stale`, and a bad signature is refused. Why `STRICT` with an `ANY` column: a non-STRICT `ANY`
|
||||||
|
column gets NUMERIC affinity and turns the text `"007"` into the integer 7;
|
||||||
|
STRICT keeps the written type, which is the cipher flag.
|
||||||
|
|
||||||
|
## Cache database — `/meta-core/db-cache/cache.db`
|
||||||
|
|
||||||
|
Never backed up (the store app lists the folder in `backup.exclude`).
|
||||||
|
Deleting it is the recovery procedure: meta-core rebuilds it from truth at
|
||||||
|
boot (no `meta:applied_seq` → full rebuild), and `local_file` refills from a
|
||||||
|
rescan.
|
||||||
|
|
||||||
|
It keeps the **v1 key/value layout** (`internal/storage/client.go`): one
|
||||||
|
`kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID` table, whose rows are
|
||||||
|
all derived — no client writes `file:` rows any more.
|
||||||
|
|
||||||
|
| `kv` rows | Holds | Written by | Read by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `file:<rid>/<field>` | the **merged view**, one row per `(rid, key)` — the only rows under `file:`. Includes the synthesised `cids/<cid> = "true"` members (from `cid_group`) and the box-local `filePath` / `sizeByte` / `mtimeNano` (from `local_file`) | apply loop | every record read; search |
|
||||||
|
| `cid:<cid>` | cid → rid (copy of truth `cid_group`) | apply loop, watcher | every cid-addressed request |
|
||||||
|
| `idx:roots/<rid>` | the set of every record | apply loop | listing, counts |
|
||||||
|
| `idx:fts/<rid>` | rowid of the record's `fts` row | apply loop | FTS maintenance |
|
||||||
|
| `idx:count/*`, `idx:schema/*` | record count, per-field schema counters | the cache engine's change hook, from `file:` deltas | search `total`, `/api/stats`, `/api/schema` |
|
||||||
|
| `ev:<stream>/<ms13>-<seq10>`, `evhwm:<stream>` | `meta:events` (every `file:` change, same transaction) and `file:events` (watcher) | cache engine; watcher | `/api/events/meta`, `/api/events/files` (SSE) |
|
||||||
|
| `meta:applied_seq`, `meta:rebuilt_at` | how far the cache has applied truth; last rebuild (older SSE cursors get `gap`) | apply loop, rebuild | boot catch-up; SSE |
|
||||||
|
| anything else | service scratch KV (`btmagnet:*`, `searchcov:*`, `nzb:*`) | other services via `/api/kv/value` (reserved prefixes refused) | same |
|
||||||
|
|
||||||
|
| Index / table | Shape | Written by | Read by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `kv_field_v` (index) | `kv(lower(field parsed from k), lower(v), v)` over `file:` rows only | — | filter search |
|
||||||
|
| `fts` | FTS5 trigram `(rid UNINDEXED, hay)`, one row per record | apply loop, when a haystack field's merged value changes | free-text search |
|
||||||
|
| `claim_by_pid` | `(pid, cid, key)` PK | apply loop | `?as=<uid>` overlay, `/api/udl/*` facade, identity retraction, `/api/udl/users/stats` |
|
||||||
|
| `local_file` | `path` PK → `midhash`, `size`, `mtime`; index `(midhash, path)` | file watcher, `POST /api/files/register` | record view (`filePath`, `sizeByte`, `mtimeNano`) |
|
||||||
|
|
||||||
|
The cache is **behind, never wrong**: after an unclean shutdown, boot finds
|
||||||
|
`meta:applied_seq < last_seq` and re-applies every truth claim with `seq >
|
||||||
|
applied_seq` (or rebuilds in full when one of them is a `cids/*` link). Write
|
||||||
|
path, apply loop and durability are detailed in
|
||||||
|
[`metacore-storage-engine.md`](../../../docs/project-architecture/metacore-storage-engine.md) §4.
|
||||||
|
|
||||||
|
## Keys
|
||||||
|
|
||||||
|
The `key` column carries the same field paths METADATA_KEYS.md defines —
|
||||||
|
what used to follow `file:<rid>/`:
|
||||||
|
|
||||||
```
|
```
|
||||||
file:<uuid>/<property/path> → Redis STRING (always)
|
claim(cid=bagacbabaec…, key=title, uid=<tmdb plugin>) = "Inception"
|
||||||
|
claim(cid=bagacbabaec…, key=fileinfo/duration, uid=<ffmpeg plugin>) = "8878.5"
|
||||||
|
claim(cid=bagacbabaec…, key=fileinfo/streamdetails/video/0/codec, uid=<ffmpeg plugin>) = "h264"
|
||||||
|
claim(cid=bagacbabaec…, key=genres/Drama, uid=<tmdb plugin>) = "true"
|
||||||
|
claim(cid=bagacbabaec…, key=cids/bafybeigd…, uid=<full-hash plugin>) = "true"
|
||||||
```
|
```
|
||||||
|
|
||||||
UUIDs are produced by `storage.NewUUID()` (`internal/storage/uuid.go`):
|
- `/` is the path separator; nested objects are flattened on write and
|
||||||
UUIDv7 → 26-char Crockford Base32 (skips I/L/O/U; ULID-compatible). The
|
reconstructed on read.
|
||||||
time-sortable prefix lets you `KEYS file:01JKR8*` to find roots minted
|
- **Key-sets** put the member inside the key (`genres/<g>`, `cids/<cid>`,
|
||||||
in a given window, which restored some of the debug-ability lost when
|
`titles/eng/<name>`, `anchoredBy/tmdb:123`): one claim per member, union by
|
||||||
midhash256 stopped being the root.
|
the ordinary merge, removal by a signed retraction.
|
||||||
|
- Plugins keep writing under their prefixes (`{plugin-id}/<field>`,
|
||||||
|
`{plugin-id}/<group>/<n>/<field>`), each signing with its own key. Two
|
||||||
|
sources compete on one key only when they make the same kind of statement;
|
||||||
|
a different fact (e.g. a filename guess vs a TMDB lookup) gets a different
|
||||||
|
key.
|
||||||
|
|
||||||
Properties use `/` as the path separator. Nested objects in source code
|
## Records: `cid_group` and the hidden rid
|
||||||
are flattened to flat keys on write and reconstructed on read. Examples:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/filePath = "/files/watch/movies/Inception.2010.mkv"
|
votes (truth, signed) cid_group (truth) cache kv rows (merged)
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/sizeByte = "15032385536"
|
(A, M, title) = "The Matrix" M → R1 file:R1/title = "The Matrix"
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/mtimeNano = "1729872000000000000"
|
(A, M, cids/Q) = "true" Q → R1 file:R1/cids/M = "true" (synthesised)
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/title = "Inception"
|
(B, Q, title) = "Matrix, The" file:R1/cids/Q = "true" (synthesised)
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/duration = "8878.5"
|
file:R1/sizeByte = "734003200"
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/streamdetails/video/0/codec = "h264"
|
(B, Q, sizeByte) = "734003200"
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/tmdb/poster = "bafkreih..."
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reverse-index aliases (CIDs)
|
- A text `cids/<other>` vote on cid X links X and `<other>` into one
|
||||||
|
group. Retracting it does **not** split the group (phase 1). In the merged
|
||||||
|
view, membership is synthesised from `cid_group` — `cids/<cid> = "true"` for
|
||||||
|
every cid of the group — and `cids/*` claims are never merged. Record R = every vote on every cid of R's group, merged per key,
|
||||||
|
each signer counted once per key.
|
||||||
|
- First-seen cid with no link → new group with a fresh rid
|
||||||
|
(`storage.NewUUID()`, UUIDv7 → 26-char Crockford Base32, ULID layout).
|
||||||
|
- Linking two groups → the **older rid survives**; the other group's cids
|
||||||
|
move to it; `/api/events/meta` emits `del` for each key of the losing rid
|
||||||
|
and `set` for the survivor. There is no rid alias table.
|
||||||
|
- `GET /api/meta/<cid>` is a `cid:<cid>` lookup in the cache, then a range
|
||||||
|
read `[file:<rid>/, file:<rid>0)` — any known cid, any hash algorithm, no
|
||||||
|
primary.
|
||||||
|
- Rids are local names: never signed, never on the swarm or gateway wire.
|
||||||
|
Local consumers (meta-sort, meta-fuse, meta-stremio) do hold them, which is
|
||||||
|
why `cid_group` lives in truth.
|
||||||
|
|
||||||
Every CID known for a file is registered as its own Redis STRING, keyed by
|
There is **no stored `canonical_cid`**. `GetMetadataDocument` ranks the
|
||||||
the **bare CID** — no algorithm prefix. A CIDv1 already encodes its hash
|
group's cids through `internal/cid/rank.go` (`cid.Better`) and returns the
|
||||||
function in the multicodec, so naming it again in the key would be
|
winner as `canonical_cid` in the JSON response only.
|
||||||
redundant (and would let the same digest be indexed under two spellings):
|
|
||||||
|
|
||||||
```
|
## Merge
|
||||||
cid:<bareCid> → <uuid>
|
|
||||||
```
|
|
||||||
|
|
||||||
Examples:
|
The cache row `file:<rid>/<key>` holds the winner over the group's votes
|
||||||
|
(each signer's newest vote counts once; retractions and ciphertext drop out):
|
||||||
```
|
most distinct signers (values compared after **trim + case-fold only**, no
|
||||||
cid:bafkreih5kapbjzq... → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
NFC) → oldest `ts` → byte-wise smallest. The stored value is the winner's
|
||||||
cid:bagacbabaec7v3... → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
exact text; `nsrc` is not stored (`POST /api/merge` returns it). With
|
||||||
```
|
`?as=<uid>`, the viewer's own live text votes (found via `claim_by_pid`)
|
||||||
|
override at read time; they are never written to the cache. Box-local
|
||||||
The reverse index makes `GET /api/meta/<cid>` an O(1) lookup for any
|
fields: when several local paths hold the same content, `filePath` is the
|
||||||
registered CID, independent of which hash algorithm — there is no primary.
|
lexicographically first (size and mtime from the same `local_file` row); a
|
||||||
The auto-alias hook (`maybeAddAliasFromFieldLocked` in
|
signed `sizeByte` claim wins over the on-disk size. Full rules:
|
||||||
`internal/storage/cid_resolution.go`, called from `SetProperty`,
|
implementation basis §7 (`internal/storage/merge.go`).
|
||||||
`SetMetadataFlat` and `MergeMetadataFlat`) registers the alias for any
|
|
||||||
field whose name starts with `cids/`, so writers emit key-set members
|
|
||||||
without knowing the reverse index exists.
|
|
||||||
|
|
||||||
> ⚠ The hook matches **only** the `cids/` prefix. A legacy `cid_*` or
|
|
||||||
> `midhash256` *named* field written to a root is stored verbatim and is
|
|
||||||
> **never indexed** — the record becomes unresolvable by that CID. Writers
|
|
||||||
> must collapse such fields to `cids/<cid>` members before the write. See
|
|
||||||
> `METADATA_KEYS.md` §14.13.
|
|
||||||
|
|
||||||
### Per-root sets
|
|
||||||
|
|
||||||
```
|
|
||||||
file:<uuid>/cids/<bareCid> → STRING "true" — one key per known CID (a key-set, not a Redis SET)
|
|
||||||
file:<uuid>/duplicates → Redis SET of alternate paths with same content
|
|
||||||
```
|
|
||||||
|
|
||||||
`cids` is a **key-set**: one flat `file:<uuid>/cids/<cid>` STRING per member,
|
|
||||||
which keeps it conflict-free to merge across peers (a SET would not be).
|
|
||||||
The record's own address (the midhash) is just the `0x1000` member — it has
|
|
||||||
no separate field.
|
|
||||||
|
|
||||||
There is **no stored `canonical_cid`**. The canonical CID is *derived on
|
|
||||||
read*: `GetMetadataDocument` (`internal/storage/cid_resolution.go`) ranks the
|
|
||||||
key-set's members through `internal/cid/rank.go` (`cid.Better`) and returns
|
|
||||||
the winner as the `canonical_cid` field of the JSON response only. Nothing
|
|
||||||
reconciles or persists it — an earlier `reconcileCanonicalCIDLocked` did, and
|
|
||||||
was removed with the flat `cid_*` keys.
|
|
||||||
|
|
||||||
### Global index
|
|
||||||
|
|
||||||
```
|
|
||||||
file:__index__ → Redis SET of all UUIDs
|
|
||||||
```
|
|
||||||
|
|
||||||
`internal/storage/client.go::GetAllHashIDs` reads this set to enumerate
|
|
||||||
every file without scanning the keyspace.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SMEMBERS file:__index__ # all UUIDs
|
|
||||||
SCARD file:__index__ # total file count
|
|
||||||
SISMEMBER file:__index__ <uuid>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Schema-version sentinel
|
|
||||||
|
|
||||||
```
|
|
||||||
meta-core:schema-version → STRING (currently "2")
|
|
||||||
```
|
|
||||||
|
|
||||||
Set at first boot, checked on every subsequent boot
|
|
||||||
(`internal/storage/schema_version.go`). If the sentinel is missing but
|
|
||||||
`file:__index__` is non-empty, the gate refuses to boot (legacy data).
|
|
||||||
If the sentinel is present but doesn't match `SchemaVersion`, the gate
|
|
||||||
also refuses to boot. Alpha-stage policy: operators wipe and restart;
|
|
||||||
there is no automated migration.
|
|
||||||
|
|
||||||
## Why flat STRING keys?
|
|
||||||
|
|
||||||
The store does not use Redis Hashes. The reasons are:
|
|
||||||
|
|
||||||
1. **Field-level keyspace notifications.** Redis publishes
|
|
||||||
`__keyspace@0__:<key>` on every SET/DEL of a STRING key. With Hashes
|
|
||||||
you'd only get one notification on the hash itself. meta-core's
|
|
||||||
`MetaPublisher` (`internal/events/meta_publisher.go`) consumes these
|
|
||||||
notifications and publishes structured events to the `meta:events`
|
|
||||||
Redis Stream.
|
|
||||||
2. **Selective updates without read-modify-write.** A plugin writing
|
|
||||||
`tmdb/poster` doesn't have to read the rest of the document.
|
|
||||||
3. **Compatibility with the CID alias hooks.** Field writes can trigger
|
|
||||||
alias-maintenance work in `SetProperty` without the plugin caring.
|
|
||||||
|
|
||||||
## Reads and writes
|
## Reads and writes
|
||||||
|
|
||||||
The Go API in `internal/storage/client.go`:
|
| Operation | Endpoint | Store access |
|
||||||
|
|---|---|---|
|
||||||
|
| write metadata | `POST /api/claims` (batch of signed claims) | truth upsert, then the apply loop updates the cache |
|
||||||
|
| removed writes | `PUT/PATCH/DELETE /meta/*`, `POST /meta/{h}/_add/{k}`, `PUT/DELETE /api/metadata/{id}`, `PUT /api/metadata/{id}/property`, `POST /api/metadata/batch` | `410 Gone`, pointing at `POST /api/claims` |
|
||||||
|
| read a record | `GET /api/meta/{cid}`, `GET /api/metadata/{rid}`, `GET /file/{cid}`, `GET /meta/{h}` | `cid:` lookup → `file:<rid>/*` rows, optional `?as=` |
|
||||||
|
| list / search | `GET /api/metadata/hash-ids`, `/list`, `POST /api/metadata/search`, `GET /api/files/tuples`, `/api/schema`, `/api/stats` | `file:` rows, `kv_field_v`, `fts`, `idx:count/*` / `idx:schema/*` |
|
||||||
|
| raw votes | `GET /api/meta/{cid}/claims`, `GET /api/metadata/{rid}/claims` (`?key=`, `?uid=`) | `{rid, cids, claims}`: every live signed claim of the group, all signers, tombstones excluded; 404 for an unknown cid / rid |
|
||||||
|
| merge without storing | `POST /api/merge` `{claims, as?}` → `{fields, nsrc, rejected}` | none — verify + merge in memory (plus `as`'s votes this core holds) |
|
||||||
|
| watcher facts | the watcher, `POST /api/files/register` | `local_file` + `cid_group` |
|
||||||
|
| service scratch | `PUT/GET/DELETE /api/kv/value` | non-reserved cache `kv` rows |
|
||||||
|
| wipe | `POST /api/metadata/clear` | both databases |
|
||||||
|
|
||||||
| Method | Behaviour |
|
Truth writes go through **one writer goroutine** with group commit; reads use
|
||||||
|---|---|
|
a separate pool and see committed state (WAL). Read paths and shapes are
|
||||||
| `GetMetadataFlat(uuid)` | `SCAN` for `file:<uuid>/*`, `MGET` the values, return `map[string]string`. |
|
unchanged from v1.x — they are now served from the derived `file:` rows.
|
||||||
| `SetMetadataFlat(uuid, m)` | Pipeline `SET` per field; `SADD file:__index__`; auto-register an alias for every `cids/<cid>` member. |
|
|
||||||
| `MergeMetadataFlat(uuid, m)` | Like `SetMetadataFlat` but doesn't pre-clear; used by `PATCH /meta/{hash}`. |
|
|
||||||
| `GetProperty(uuid, field)` | `GET file:<uuid>/<field>`. |
|
|
||||||
| `SetProperty(uuid, field, value)` | `SET file:<uuid>/<field>`; auto-register alias if the field is a CID. |
|
|
||||||
| `DeleteRoot(uuid)` | `SCAN` + `DEL` for `file:<uuid>/*`, unmap every reverse-index entry. |
|
|
||||||
| `Mint(filePath, size, mtimeNano)` | `NewUUID()` + write `filePath/sizeByte/mtimeNano` + add to index. |
|
|
||||||
| `AddAlias(uuid, "<bareCid>")` | Set `cid:<bareCid>` → `<uuid>` and the key-set member `file:<uuid>/cids/<bareCid>` = `"true"`. Nothing to reconcile — canonical is derived on read. |
|
|
||||||
| `ResolveRoot("<bareCid>")` | `GET cid:<bareCid>` → uuid (returns the input unchanged if it is already a UUID root). |
|
|
||||||
| `GetMetadataDocument(uuid)` | Flat fields + `cids` hoisted to an array + `canonical_cid` **derived** by `cid.Better` rank + `duplicates`. Backs `GET /api/meta/{cid}`. |
|
|
||||||
|
|
||||||
## Reconstruction (flat → nested)
|
## Reconstruction (flat → nested)
|
||||||
|
|
||||||
When the HTTP API returns a document, it reconstructs the flattened
|
The HTTP API reconstructs the merged flat keys into a nested JSON object:
|
||||||
storage back into a nested JSON object:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
flat nested
|
flat nested
|
||||||
@@ -186,112 +205,122 @@ fileinfo/streamdetails/ streamdetails: { video:
|
|||||||
streams/0/language = "eng" streams: [ { language: "eng" }, ... ]
|
streams/0/language = "eng" streams: [ { language: "eng" }, ... ]
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
- Numeric path segments become array indices on read.
|
||||||
|
- All values are strings on the wire; type interpretation is the caller's
|
||||||
|
job (documented per field in `METADATA_KEYS.md`).
|
||||||
|
- Box-local fields from `local_file` are joined in unsigned; they never
|
||||||
|
appear in `GET /api/meta/{cid}/claims` or on the swarm.
|
||||||
|
|
||||||
- `/` is the path separator.
|
## Search structures
|
||||||
- Numeric path segments (`0`, `1`, ...) become array indices on read.
|
|
||||||
- All values are strings on the wire; type interpretation is the
|
|
||||||
caller's job (and is documented per-field in `METADATA_KEYS.md`).
|
|
||||||
- `null`/`undefined` round-trip as empty strings.
|
|
||||||
|
|
||||||
## Plugin contribution flow
|
Both live in the cache and are maintained in the same transaction as the
|
||||||
|
merged-row change, so results are fresh as soon as a change is applied:
|
||||||
|
|
||||||
Plugins don't talk to Redis. They write metadata via meta-core's HTTP
|
- **`kv_field_v`** — an expression index over the field name parsed out of
|
||||||
API (`PUT /meta/{hash}/{key...}` or `PATCH /meta/{hash}`). Each plugin
|
the key, `lower(v)` and `v`, restricted to `file:` rows. Answers
|
||||||
writes under a prefix:
|
"which records have field F with a value like V" for any field, including
|
||||||
|
ones invented tomorrow; covering for the substring test.
|
||||||
|
- **`fts`** — FTS5 trigram `(rid UNINDEXED, hay)`, one row per record: the
|
||||||
|
haystack of its searchable merged fields plus the rid. Every 3+ character substring is
|
||||||
|
indexable.
|
||||||
|
|
||||||
```
|
`POST /api/metadata/search` builds a candidate superset in SQL and verifies
|
||||||
{plugin-id}/<field> single value
|
each hydrated merged record with the Go matchers (`recordMatchesFilter`,
|
||||||
{plugin-id}/<group>/<field> grouped
|
`matchAllTokens`); results come in rid order. Plan, rejected shapes and the
|
||||||
{plugin-id}/<group>/<n>/<field> indexed (array)
|
measurement status are in
|
||||||
```
|
[`metacore-storage-engine.md`](../../../docs/project-architecture/metacore-storage-engine.md) §8.
|
||||||
|
|
||||||
See [`METADATA_KEYS.md`](../../../METADATA_KEYS.md) for the full,
|
## Schema-version sentinel
|
||||||
authoritative list of fields each plugin writes (content identification,
|
|
||||||
file stats, video classification, anime detection, subtitle references,
|
Truth `meta` row `schema-version` = `"3"`, set at first boot and checked on
|
||||||
Jellyfin metadata, FFmpeg stream details, etc.) and the value formats
|
every boot (`internal/storage/schema_version.go`), after both files are
|
||||||
(`int-string`, `lang3`, `path-relfiles`, `csv-set`, ...). This doc
|
opened and before the cache catch-up. A truth file holding the v1 `kv` table
|
||||||
deliberately does not duplicate that registry — it would drift.
|
(v1.x, `meta-core:schema-version = "2"`) is **explicitly refused**, naming the
|
||||||
|
migration tool; claims without a sentinel, or another version, are refused
|
||||||
|
too; a fresh store is stamped. Alpha policy: no migration code in the
|
||||||
|
service binary; operators wipe and restart. watch.nsl.sh is converted once by
|
||||||
|
the separate one-off tool `packages/meta-core/tools/claims-migrate` (not in
|
||||||
|
the image; rids keep their UUIDs; legacy fields re-signed by a throwaway key;
|
||||||
|
UDL cells re-signed by their account keys — implementation basis §11).
|
||||||
|
|
||||||
## Event streams
|
## Event streams
|
||||||
|
|
||||||
Two Redis Streams are produced inside meta-core:
|
|
||||||
|
|
||||||
| Stream | Producer | Contents |
|
| Stream | Producer | Contents |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `file:events` | `internal/watcher` dispatcher | `add` / `change` / `delete` / `rename` / `reset` events for files on the `/files` volume. |
|
| `/api/events/meta` | the cache engine, as `ev:meta:events/…` rows of the cache `kv` table | `{type: set\|del, key, ts}` for every change of a **merged** value, in the same cache transaction as the change |
|
||||||
| `meta:events` | `internal/events.MetaPublisher` | Field-level metadata mutations derived from Redis keyspace notifications. |
|
| `/api/events/files` | `internal/watcher` dispatcher (`ev:file:events/…` rows) | `add` / `change` / `delete` / `rename` / `reset` for files on `/files` (unchanged) |
|
||||||
|
|
||||||
External consumers do **not** read these streams directly. They subscribe
|
- A vote that does not change the merged value emits nothing.
|
||||||
via SSE:
|
- A group merge emits `del` for every key of the losing rid and `set` for the
|
||||||
|
survivor's keys.
|
||||||
- `GET /api/events/files` — mirror of `file:events`
|
- IDs keep the `<ms>-<seq>` form and stay time-based, so persisted cursors
|
||||||
- `GET /api/events/meta` — mirror of `meta:events`
|
keep comparing, including across a cache rebuild.
|
||||||
|
- `Last-Event-ID: 0` / `0-0` returns a snapshot (one `set` per merged key)
|
||||||
The SSE wire contract uses `Last-Event-ID` for resume (the opaque
|
then the live tail; only the last snapshot event carries an `id:`.
|
||||||
`<ms>-<seq>` Redis Stream entry ID). Heartbeats are SSE comments
|
- A trimmed cursor gets one `event: gap`. A cache rebuild emits no per-key
|
||||||
(`:keep-alive\n\n`) every 30s of silence. If retention has trimmed past
|
events; consumers get `gap` and take a snapshot.
|
||||||
a client's cursor, the handler emits one `event: gap` payload before
|
- Heartbeats are SSE comments (`:keep-alive\n\n`) every 30 s of silence.
|
||||||
resuming. See `internal/api/sse_events.go` and
|
Contract: `internal/api/sse_events.go`, [`api-mediated-access.md`](api-mediated-access.md).
|
||||||
[`api-mediated-access.md`](api-mediated-access.md) for the full
|
|
||||||
contract.
|
|
||||||
|
|
||||||
Internally, meta-core uses a Redis consumer group (XREADGROUP / XACK)
|
|
||||||
inside `MetaPublisher` to drain keyspace notifications, but this is not
|
|
||||||
the external surface — external services use SSE.
|
|
||||||
|
|
||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
|
Paths: truth `/meta-core/db/meta-core.db`, cache `/meta-core/db-cache/cache.db`
|
||||||
|
(image default and store app); dev stacks keep both on named volumes (the
|
||||||
|
`/meta-core` bind is 9p on Windows dev boxes, where SQLite WAL is unsafe).
|
||||||
|
Always open **read-only**; writes go through `POST /api/claims`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Roots
|
T=/meta-core/db/meta-core.db; C=/meta-core/db-cache/cache.db # dev: the named-volume paths
|
||||||
docker exec metacore-app redis-cli SMEMBERS 'file:__index__'
|
QT() { docker exec metacore-app sqlite3 -readonly "$T" "$1"; }
|
||||||
docker exec metacore-app redis-cli SCARD 'file:__index__'
|
QC() { docker exec metacore-app sqlite3 -readonly "$C" "$1"; }
|
||||||
docker exec metacore-app redis-cli SRANDMEMBER 'file:__index__'
|
|
||||||
|
|
||||||
# Full document for one root (flat scan)
|
QT "SELECT rid FROM cid_group WHERE cid = 'bafkreih...'" # cid → rid
|
||||||
docker exec metacore-app redis-cli --scan --pattern 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/*'
|
QT "SELECT cid FROM cid_group WHERE rid = '01JKR8XW5T4QMV7AHNJ2DEFGHK'" # the group (full scan: no rid index)
|
||||||
|
QC "SELECT k, v FROM kv WHERE k >= 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/' AND k < 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK0'" # merged record
|
||||||
# One property
|
QT "SELECT c.key, typeof(c.value), c.value, p.uid, c.ts, c.seq
|
||||||
docker exec metacore-app redis-cli GET 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/duration'
|
FROM claim c JOIN principal p USING(pid) WHERE c.cid = 'bafkreih...'" # every vote on one cid
|
||||||
|
QT "SELECT name, value FROM meta"; QC "SELECT k, v FROM kv WHERE k LIKE 'meta:%'" # last_seq vs applied_seq
|
||||||
# Confirm keys are STRING (not HASH)
|
QC "SELECT path, midhash FROM local_file WHERE path LIKE '/files/watch/%' LIMIT 10"
|
||||||
docker exec metacore-app redis-cli TYPE 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/filePath'
|
QC "SELECT k, v FROM kv WHERE k >= 'ev:meta:events/' AND k < 'ev:meta:events0' ORDER BY k DESC LIMIT 10"
|
||||||
# string
|
|
||||||
|
|
||||||
# Reverse-index lookup (bare CID — no algorithm prefix)
|
|
||||||
docker exec metacore-app redis-cli GET 'cid:bafkreih...' # → uuid
|
|
||||||
|
|
||||||
# The cids key-set (one STRING per member, NOT a Redis SET — SMEMBERS won't work)
|
|
||||||
docker exec metacore-app redis-cli --scan --pattern 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids/*'
|
|
||||||
|
|
||||||
# canonical_cid is NOT stored. Derive it via the API instead:
|
|
||||||
curl -k https://metacore-dev.localhost:8083/api/meta/<cid> | jq '.canonical_cid, .cids'
|
|
||||||
|
|
||||||
# Schema-version sentinel
|
|
||||||
docker exec metacore-app redis-cli GET 'meta-core:schema-version'
|
|
||||||
|
|
||||||
# Streams (server-side; external consumers should use SSE)
|
|
||||||
docker exec metacore-app redis-cli XRANGE 'file:events' - + COUNT 10
|
|
||||||
docker exec metacore-app redis-cli XRANGE 'meta:events' - + COUNT 10
|
|
||||||
|
|
||||||
# HTTP equivalents — preferred
|
# HTTP equivalents — preferred
|
||||||
curl -k 'https://metacore-dev.localhost:8083/api/meta/<cid>' # CID → document
|
curl -k 'https://metacore-dev.localhost:8083/api/meta/<cid>' # merged document (+ canonical_cid)
|
||||||
curl -k 'https://metacore-dev.localhost:8083/meta' # list root IDs
|
curl -k 'https://metacore-dev.localhost:8083/api/meta/<cid>/claims' # signed votes
|
||||||
curl -k 'https://metacore-dev.localhost:8083/meta/<uuid>' # document by UUID
|
curl -k 'https://metacore-dev.localhost:8083/api/events/meta' # live SSE
|
||||||
curl -k 'https://metacore-dev.localhost:8083/api/events/meta' # live SSE
|
|
||||||
curl -k 'https://metacore-dev.localhost:8083/api/kv/info' # KV stats
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key implementation files
|
## Key implementation files
|
||||||
|
|
||||||
| Component | Path |
|
| Component | Path |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Redis client + flat-key ops | `internal/storage/client.go` |
|
| Single-writer, group-commit SQLite engine | `internal/kv/` |
|
||||||
|
| Truth, apply loop, merge, local files, counters | `internal/storage/{truth,apply,merge,localfile,client}.go` |
|
||||||
|
| Search (`kv_field_v` + FTS) | `internal/storage/search.go` |
|
||||||
|
| MMUR2 claim encoding + verification | `internal/claim/` (secp256k1 in `internal/identity/`) |
|
||||||
| UUIDv7 minting | `internal/storage/uuid.go` |
|
| UUIDv7 minting | `internal/storage/uuid.go` |
|
||||||
| CID reverse index + aliases + canonical-CID reconciliation | `internal/storage/cid_resolution.go` |
|
| Canonical-CID rank | `internal/cid/rank.go` |
|
||||||
| Schema-version gate | `internal/storage/schema_version.go` |
|
| Schema-version gate | `internal/storage/schema_version.go` |
|
||||||
| Dual-root migration (legacy clean-up) | `internal/storage/dual_root_migration.go` |
|
| SSE event mirror + snapshot | `internal/api/sse_events.go` |
|
||||||
| Keyspace-notification → `meta:events` publisher | `internal/events/meta_publisher.go` |
|
| Watcher → `local_file`, `/api/events/files` | `internal/watcher/` |
|
||||||
| Watcher → `file:events` dispatcher | `internal/watcher/dispatcher.go` |
|
|
||||||
| SSE event mirror | `internal/api/sse_events.go` |
|
|
||||||
| Field semantics (authoritative registry) | [`/METADATA_KEYS.md`](../../../METADATA_KEYS.md) |
|
| Field semantics (authoritative registry) | [`/METADATA_KEYS.md`](../../../METADATA_KEYS.md) |
|
||||||
|
|
||||||
|
## Previous layout (v1.1–v1.2 `kv` table)
|
||||||
|
|
||||||
|
Kept so old notes and scripts can be read. In v2.0.0 the **truth** database
|
||||||
|
has no `kv` table at all; the **cache** keeps the same key layout for derived
|
||||||
|
rows (see above), but nothing in it is written by clients.
|
||||||
|
|
||||||
|
| Key | Value | Replaced by |
|
||||||
|
|---|---|---|
|
||||||
|
| `file:<rid>/<field>` | one value per field, last writer wins | `claim` (one row per signer) → derived cache `file:` rows |
|
||||||
|
| `cid:<cid>` | `<rid>` | `cid_group` (truth), copied to cache `cid:` rows |
|
||||||
|
| `idx:roots/<rid>`, `idx:count/*`, `idx:schema/*`, `idx:fts/<rid>` | root set, counters, FTS rowid | same keys, derived, in the cache |
|
||||||
|
| `dup:<rid>/<path>` | duplicate path | `local_file` rows |
|
||||||
|
| `udl:rec:<uid>/<cid>/<key>`, `udl:idx:*` | User Data Layer cells (MMUR1, `version`) | `claim` (MMUR2, `ts`) + `claim_by_pid` |
|
||||||
|
| `ev:<stream>/<ms13>-<seq10>`, `evhwm:<stream>` | stream rows | same keys, in the cache |
|
||||||
|
| `btmagnet:*`, `searchcov:*`, `nzb:*` | service scratch | same keys, in the cache |
|
||||||
|
| `meta-core:schema-version` = `"2"` | gate | truth `meta` `schema-version` = `"3"` |
|
||||||
|
|
||||||
|
The flat-key idea survived — a field is still one string key, a key-set
|
||||||
|
member is still inside the key — but the record moved from "one value per
|
||||||
|
field" to "one value per signer per field".
|
||||||
|
|||||||
+201
-287
@@ -2,361 +2,275 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Status:** Implemented (2026 alpha clean-wipe gate landed). Kept for design rationale.
|
**Status:** Design record. Implemented in 2026 alpha (clean-wipe gate);
|
||||||
|
**reframed by meta-core v2.0.0 (claims model)** — see
|
||||||
|
[`docs/implementation/metacore-claims-model.md`](../../../docs/implementation/metacore-claims-model.md)
|
||||||
|
§4. This doc keeps the *why* behind UUID rids and CID symmetry; it is not
|
||||||
|
the schema reference. For what is stored, see
|
||||||
|
[metadata-storage-structure.md](./metadata-storage-structure.md) and
|
||||||
|
`METADATA_KEYS.md` §2/§14.13.
|
||||||
|
|
||||||
> ⚠ **Two parts of this proposal were superseded before shipping.** This doc
|
What still holds, unchanged since the original proposal:
|
||||||
> is the design record; it is *not* the schema reference. For what is actually
|
|
||||||
> stored, see `METADATA_KEYS.md` §2/§14.13 and
|
- **No CID is privileged.** Any known CID resolves to its record; all hash
|
||||||
> [metadata-storage-structure.md](./metadata-storage-structure.md).
|
types are symmetric.
|
||||||
|
- **Rids are UUIDv7**, 26-char Crockford Base32, **minted locally** with no
|
||||||
|
central allocator.
|
||||||
|
- **`canonical_cid` is derived on read** by rank (`internal/cid/rank.go` →
|
||||||
|
`cid.Better`), never stored.
|
||||||
|
- **`cids` is a key-set of bare CIDs** (`cids/<bareCid>` = `"true"`), no
|
||||||
|
`<algo>:` prefix — a CIDv1's multicodec already names the hash function.
|
||||||
|
|
||||||
|
What changed in v2.0.0:
|
||||||
|
|
||||||
|
> 1. **The rid is a hidden local name for a group of cids.** Metadata is no
|
||||||
|
> longer stored under `file:<uuid>/<field>`. Every value is a signed claim
|
||||||
|
> about a **cid** (`claim(uid, cid, key) → value`); `cid_group(cid → rid)`
|
||||||
|
> says which cids form one record. The rid is **derived from cid groups,
|
||||||
|
> never signed and never on the wire** (swarm, gateway); each box mints its
|
||||||
|
> own.
|
||||||
|
> 2. **The `cid:<cid>` reverse index is gone**, replaced by the `cid_group`
|
||||||
|
> table (still a primary-key lookup, so still O(1)-class).
|
||||||
|
> 3. **meta-core no longer writes `cids/*` members.** They are signed votes
|
||||||
|
> from the writers that learn a cid (e.g. the full-hash plugin); a non-null
|
||||||
|
> `cids/<other>` vote links two groups. Only the watcher's midhash enters
|
||||||
|
> `cid_group` directly.
|
||||||
|
> 4. **`duplicates` / `dup:<uuid>/<path>` are gone.** Box-local paths live in
|
||||||
|
> the cache's `local_file(path → midhash, size, mtime)`; several paths with
|
||||||
|
> the same midhash are the duplicates.
|
||||||
>
|
>
|
||||||
> 1. **`canonical_cid` is not a stored key.** The proposed reconciler
|
> Earlier superseded points, kept for the record: the proposed
|
||||||
> (`reconcileCanonicalCIDLocked`) was never kept — a stored scalar isn't
|
> `reconcileCanonicalCIDLocked` never shipped; `cids` never was a Redis SET
|
||||||
> conflict-free to merge across peers. The canonical CID is **derived on
|
> of `<algo>:<value>` tokens; Redis itself went in v1.1.0 (2026-10). Text
|
||||||
> read** by ranking the `cids` members (`internal/cid/rank.go` →
|
> below marked *(proposal)* or *(v1.x)* describes those shapes, not v2.0.0.
|
||||||
> `cid.Better`), and appears only in the `GET /api/meta/{cid}` response.
|
|
||||||
> 2. **`cids` is a key-set, not a Redis SET, and holds bare CIDs, not
|
|
||||||
> `<algo>:<value>` tokens.** One flat STRING per member:
|
|
||||||
> `file:<uuid>/cids/<bareCid>` = `"true"`. The reverse index is likewise
|
|
||||||
> `cid:<bareCid>` → uuid. A CIDv1's multicodec already names its hash
|
|
||||||
> function, so the `<algo>:` prefix was redundant.
|
|
||||||
>
|
|
||||||
> Text below that references either shape describes the *proposal*, not the
|
|
||||||
> code. The rank ordering and everything else did ship as written.
|
|
||||||
|
|
||||||
## Motivation
|
## Motivation (history)
|
||||||
|
|
||||||
Today, every metadata entry in Redis is keyed by a content hash, specifically
|
Before UUID rids, every metadata entry in Redis was keyed by a content hash,
|
||||||
midhash256:
|
specifically midhash256:
|
||||||
|
|
||||||
```
|
```
|
||||||
file:midhash256:abc.../filePath
|
file:midhash256:abc.../filePath
|
||||||
file:midhash256:abc.../title
|
file:midhash256:abc.../title
|
||||||
file:midhash256:abc.../fileinfo/duration
|
|
||||||
file:midhash256:abc.../tmdb/poster
|
file:midhash256:abc.../tmdb/poster
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Other digests (sha256, IPFS CIDs, BitTorrent infohashes) live as fields
|
Other digests (sha256, IPFS CIDs, BitTorrent infohashes) lived as fields
|
||||||
*inside* that entry:
|
*inside* that entry (`cid_sha256`, `cid_ipfs`). Two problems:
|
||||||
|
|
||||||
```
|
1. **midhash256 was privileged by accident.** It *was* the key, while every
|
||||||
file:midhash256:abc.../cid_sha256 = sha256:def...
|
other CID was a secondary field. Hash-algorithm changes, or new digest
|
||||||
file:midhash256:abc.../cid_ipfs = bafy...
|
types added by plugins, forced rewrites of every root. The privilege
|
||||||
```
|
itself was the issue, not which hash held it.
|
||||||
|
2. **CID lookup was O(n).** "Given any CID, return the metadata" scanned
|
||||||
|
every entry (`storage/client.go::LookupPathByCID`). Unacceptable for a
|
||||||
|
public API, especially when peers query by any CID variant.
|
||||||
|
|
||||||
Two problems with this:
|
The requirement: **any CID a client knows must resolve to that content's
|
||||||
|
metadata in O(1)**, and the storage layout must not bias toward any digest
|
||||||
1. **midhash256 is privileged by accident.** It IS the key, while every other
|
type. The claims model adds a second one: **what is signed must name
|
||||||
CID is a secondary field. There's nothing intrinsically special about
|
something every peer agrees on** — a rid minted at random on one box does
|
||||||
midhash256 — it's just the first hash computed. Hash-algorithm changes, or
|
not qualify, a cid does.
|
||||||
new digest types added by plugins, force rewrites of every root. The
|
|
||||||
"promotion" workarounds people reach for (re-keying entries when a stronger
|
|
||||||
hash arrives) solve the wrong problem; the privilege itself is the issue.
|
|
||||||
|
|
||||||
2. **CID lookup is O(n).** Resolving "given any CID, return that file's
|
|
||||||
metadata" currently scans every indexed entry (see
|
|
||||||
`storage/client.go::LookupPathByCID`, which acknowledges this in a
|
|
||||||
comment). Acceptable for admin tooling, unacceptable for a public API
|
|
||||||
surface — especially when peers on meta-share may query by any CID variant.
|
|
||||||
|
|
||||||
The product requirement is clear: **any CID a client knows about must resolve
|
|
||||||
to that file's metadata in O(1).** IPFS-compatible CIDs are the externally
|
|
||||||
meaningful identity for content retrieval; the internal storage layout should
|
|
||||||
not bias toward any one digest type.
|
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
A single externally-meaningful contract:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/meta/<cid> → full metadata document for that file
|
GET /api/meta/<cid> → full (merged) metadata document for that content
|
||||||
```
|
```
|
||||||
|
|
||||||
Where `<cid>` may be any supported type (midhash256, sha256, IPFS, btih,
|
`<cid>` may be any supported type (midhash, sha256, IPFS, btih, sha3, future
|
||||||
sha3, future additions). All CID types are treated symmetrically. No CID type
|
additions), all treated symmetrically.
|
||||||
is "the key."
|
|
||||||
|
|
||||||
## Design
|
## Design (v2.0.0)
|
||||||
|
|
||||||
### Two-tier key structure
|
### Votes name cids; a rid names a group
|
||||||
|
|
||||||
The root key becomes an opaque internal UUID; CIDs are aliases that point to
|
|
||||||
it.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
file:<uuid>/<property...> ← canonical storage (UUIDv7, never disclosed externally)
|
votes (signed, travel) derived (local, persistent: truth cid_group)
|
||||||
cid:<cid> → <uuid> ← reverse index (one Redis key per known CID)
|
(A, M, title) = "The Matrix" M → R1
|
||||||
|
(A, M, cids/Q) = "true" ← alias vote Q → R1
|
||||||
|
(B, Q, title) = "Matrix, The"
|
||||||
|
(B, Q, sizeByte)= "734003200" record R1 = every vote on M or Q, merged per key
|
||||||
```
|
```
|
||||||
|
|
||||||
The metadata keyspace under `file:<uuid>/` remains open and flat, exactly as
|
- **First-seen cid, no link** → new group, fresh UUIDv7 rid.
|
||||||
today — anything that wrote `file:<midhash>/fileinfo/duration` now writes
|
- **`cids/<other>` vote on X** → X and `<other>` join one group.
|
||||||
`file:<uuid>/fileinfo/duration`. Only the root identifier changes.
|
- **Link between two existing groups** → the group with the **older rid
|
||||||
|
survives**; the other's cids move to it; the losing rid gets a `del` event
|
||||||
|
for each of its keys, the survivor `set` events. No `rid_alias`: consumers
|
||||||
|
already handle deletes, and cids re-resolve.
|
||||||
|
- **Reading record R** = every vote on every cid of R's group, merged per key
|
||||||
|
(one signer counts once per key), precomputed into the cache's
|
||||||
|
`file:<rid>/<field>` rows (the v1 key layout, now derived).
|
||||||
|
- **Retracting a `cids/<other>` link does not split the group** (phase 1):
|
||||||
|
the tombstone drops out of the merge, `cid_group` keeps both cids under one
|
||||||
|
rid. In the record view, membership is synthesised from `cid_group`
|
||||||
|
(`cids/<cid> = "true"` for every cid of the group).
|
||||||
|
|
||||||
**Example layout:**
|
Why keep a rid at all, if votes never mention it: local consumers (meta-sort,
|
||||||
|
meta-fuse, meta-stremio) and the merged view need one stable name per record,
|
||||||
|
and a group's cid set grows over time. Why `cid_group` lives in **truth**
|
||||||
|
although it could be recomputed from `cids/*` votes: the rid names are minted
|
||||||
|
at random and consumers hold them, so a recomputation would rename every
|
||||||
|
record.
|
||||||
|
|
||||||
```
|
### Why UUIDv7 (still valid)
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/filePath = /watch/Inception.mkv
|
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/size = 4500000000
|
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/mtime = 1729872000000000000
|
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids/bagacbabaec… = "true" ← midhash (record address)
|
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids/bafybeigd… = "true" ← sha2-256
|
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/duplicates = SET{/watch/dup1.mkv, /watch/dup2.mkv}
|
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/title = "Inception"
|
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/duration = 7200.5
|
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/tmdb/poster = bafy...
|
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/subtitle/eng = ...
|
|
||||||
|
|
||||||
cid:bagacbabaec… → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
- **128 bits**: collision-free under distributed minting; no allocator. Each
|
||||||
cid:bafybeigd… → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
box minting its own rids for the same content is now the normal case, not a
|
||||||
```
|
corner case — which is exactly why rids never travel.
|
||||||
|
- **Time-sortable prefix**: rids minted in a window sort together
|
||||||
|
(`k >= 'file:01JKR8' AND k < 'file:01JKR9'` on the cache's merged rows),
|
||||||
|
and "older rid
|
||||||
|
survives" on a group merge is a plain string comparison.
|
||||||
|
- **Monotonic-ish**: adjacent rids cluster in the cache's `file:<rid>/…`
|
||||||
|
key range.
|
||||||
|
|
||||||
*(As shipped — the proposal originally wrote `cids` as a Redis SET of
|
Encoded as 26-char Crockford Base32 (ULID-compatible) for compactness and
|
||||||
`<algo>:<value>` tokens plus a `canonical_cid` scalar. See the Status note.)*
|
case-insensitive matching. Migrated records kept their v1.x UUIDs as rids.
|
||||||
|
|
||||||
### UUID format: UUIDv7
|
### The `cids` key-set
|
||||||
|
|
||||||
UUIDv7 chosen over UUIDv4 or 64-bit random:
|
`cids/<bareCid>` = `"true"`, one claim per member, by whichever signer knows
|
||||||
|
the link. A key-set merges conflict-free: a member is present while at least
|
||||||
|
one vote supports it, and removal is a signed retraction. That is the reason
|
||||||
|
the proposal's Redis SET was reversed before shipping, and it is what lets
|
||||||
|
the claims model treat `cids` with no special merge code.
|
||||||
|
|
||||||
- **128 bits**: collision-free under distributed minting; no central allocator.
|
Box-local file facts (`filePath`, size, `mtimeNano`, midhash) are **not**
|
||||||
- **Time-sortable prefix**: `KEYS file:01JKR8*` retrieves entries minted in a
|
claims — meta-core cannot sign and the disk can always be rescanned. The
|
||||||
given window. Restores some of the debuggability lost by going opaque.
|
watcher records them in `local_file` and puts the midhash into `cid_group`;
|
||||||
- **Monotonic-ish**: adjacent UUIDs cluster on disk; modestly better SCAN
|
the record view joins them in unsigned. When several local paths hold the
|
||||||
locality.
|
same content, `filePath` is the lexicographically first one.
|
||||||
|
|
||||||
Encoded as a 26-character Crockford Base32 string (ULID-compatible) for
|
### Canonical CID selection (unchanged)
|
||||||
compactness and case-insensitive matching.
|
|
||||||
|
|
||||||
### New root fields owned by meta-core
|
The canonical CID is a label, not a key, so nothing external breaks when it
|
||||||
|
changes. It is computed on every read from the group's cids:
|
||||||
The metadata schema is open — any writer can put any key under
|
|
||||||
`file:<uuid>/`. This proposal does not change that. It adds three keys whose
|
|
||||||
sole writer is meta-core itself:
|
|
||||||
|
|
||||||
| Key | Type (as shipped) | Purpose |
|
|
||||||
|-----|------|---------|
|
|
||||||
| `cids/<bareCid>` | STRING `"true"` — a *key-set*, one key per member | All CIDs known for this file, midhash included. Mirror of the `cid:*` reverse index. Source of truth for delete-time GC. **Proposed as a Redis SET of `<algo>:<value>` tokens; shipped as a key-set of bare CIDs** — a key-set merges conflict-free across peers and the multicodec already names the algorithm. |
|
|
||||||
| `duplicates` | Redis SET | Additional file paths whose content matches this entry. One UUID = one set of metadata; multiple physical files may share that content. Consumed by meta-dup. |
|
|
||||||
| ~~`canonical_cid`~~ | *(not stored)* | **Dropped.** Was to hold the externally-presented CID. Now derived on read by ranking the `cids` members; surfaced only on `GET /api/meta/{cid}`. |
|
|
||||||
|
|
||||||
Existing meta-core-owned fields (`filePath`, `size`, `mtime`, `midhash256`)
|
|
||||||
keep their names and writers. Plugin-written fields (`tmdb/*`, `fileinfo/*`,
|
|
||||||
`subtitle/<lang>`, …) are unaffected.
|
|
||||||
|
|
||||||
~~Redis SET is a departure from the all-strings convention in the current
|
|
||||||
storage doc, but `file:__index__` is already a SET — so the precedent exists
|
|
||||||
for using sets where they're the right structure.~~ **Reversed for `cids`.**
|
|
||||||
The all-strings convention won: `cids` ships as flat `cids/<cid>` STRING keys
|
|
||||||
so that two peers merging the same record can never disagree about set
|
|
||||||
membership. `duplicates` remained a Redis SET (it is leader-local and never
|
|
||||||
merged across peers).
|
|
||||||
|
|
||||||
### Canonical CID selection
|
|
||||||
|
|
||||||
The canonical CID is a label, not a key — so nothing external breaks when it
|
|
||||||
changes. **As shipped it is not written at all**: it is computed on every read
|
|
||||||
from the `cids` members, which makes the "update" cost zero and removes the
|
|
||||||
reconciler entirely.
|
|
||||||
|
|
||||||
Selection rule, highest-wins (this part shipped as designed —
|
|
||||||
`internal/cid/rank.go`):
|
|
||||||
|
|
||||||
1. IPFS CID (any variant)
|
1. IPFS CID (any variant)
|
||||||
2. SHA-256 / SHA-3 family
|
2. SHA-256 / SHA-3 family
|
||||||
3. BitTorrent infohash (v2 preferred over v1)
|
3. BitTorrent infohash (v2 preferred over v1)
|
||||||
4. midhash256
|
4. midhash256
|
||||||
|
|
||||||
Ranked by **utility for external retrieval**, not by cryptographic strength.
|
Ranked by **utility for external retrieval**, not cryptographic strength:
|
||||||
midhash256 and sha256 are both 256-bit, but only sha256/IPFS CIDs are
|
midhash256 and sha256 are both 256-bit, but only sha256/IPFS CIDs are
|
||||||
externally meaningful for content addressing. btih ranks above midhash because
|
externally meaningful; btih ranks above midhash because it enables swarm
|
||||||
it enables swarm interop even if it's weaker as a digest.
|
interop. The full ladder (including the custom locator codecs) lives in
|
||||||
|
`internal/cid/rank.go`, mirrored in meta-search / meta-share
|
||||||
|
(`scripts/check-cid-vectors.sh`).
|
||||||
|
|
||||||
~~Reconciler runs on the leader, recomputes `canonical_cid` whenever
|
## Lifecycles (v2.0.0)
|
||||||
`AddAlias` registers a new CID for an entry.~~ **Not shipped** — there is no
|
|
||||||
reconciler. `GetMetadataDocument` folds the members through `cid.Better` on
|
|
||||||
each read.
|
|
||||||
|
|
||||||
## Internal operations
|
### File arrival
|
||||||
|
|
||||||
The meta-core storage interface adds five operations. None are exposed
|
|
||||||
externally — they're internal to meta-core. External callers see only the
|
|
||||||
CID-based API.
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Mint creates a new root with no aliases. Called when the watcher
|
|
||||||
// computes a midhash for a previously-unseen file. Returns the new UUID.
|
|
||||||
Mint(filePath string, size int64, mtime int64) (uuid string, err error)
|
|
||||||
|
|
||||||
// AddAlias registers a CID as resolving to this UUID. Updates both the
|
|
||||||
// cid:<cid> reverse index and the file:<uuid>/cids/<cid> key-set member.
|
|
||||||
// (As shipped: no reconciliation step — canonical is derived on read.)
|
|
||||||
AddAlias(uuid string, cid string) error
|
|
||||||
|
|
||||||
// GetByCID resolves any known CID to its UUID. Returns ("", nil) if unknown.
|
|
||||||
GetByCID(cid string) (uuid string, err error)
|
|
||||||
|
|
||||||
// MergeIfAliasExists is called when the watcher mints a fresh UUID for a
|
|
||||||
// file path but, on hashing, discovers the resulting CID already belongs
|
|
||||||
// to a different UUID. Merges the new (mostly-empty) UUID into the
|
|
||||||
// existing one, transferring filePath into the duplicates set, and
|
|
||||||
// returns the canonical UUID.
|
|
||||||
MergeIfAliasExists(newUUID, cid string) (canonicalUUID string, merged bool, err error)
|
|
||||||
|
|
||||||
// DeleteRoot removes the entire entry, including every cid:* alias and
|
|
||||||
// every file:<uuid>/* key. Used for file deletion events.
|
|
||||||
DeleteRoot(uuid string) error
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lifecycle: file arrival
|
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Watcher sees /watch/foo.mkv created.
|
1. Watcher sees /files/watch/foo.mkv, computes its midhash M.
|
||||||
2. Watcher computes midhash256 → "midhash256:abc...".
|
2. local_file: /files/watch/foo.mkv → (M, size, mtime).
|
||||||
3. Watcher calls GetByCID("midhash256:abc...").
|
3. cid_group: M already grouped → nothing; otherwise M → fresh rid.
|
||||||
→ uuid_X (existing entry) or "" (new file).
|
4. Plugins process the file by cid M (the rid is never disclosed to them)
|
||||||
|
and sign claims about M with their own keys.
|
||||||
4a. If existing (uuid_X):
|
5. The full-hash plugin computes sha256 S and signs (plugin, M, cids/S) = "true".
|
||||||
- Add "/watch/foo.mkv" to file:<uuid_X>/duplicates.
|
→ S joins M's group (or the two groups merge, older rid surviving).
|
||||||
- No new root created.
|
→ The next read derives canonical_cid = S, because it outranks the midhash.
|
||||||
- meta-dup is notified via the existing keyspace event channel.
|
|
||||||
|
|
||||||
4b. If new:
|
|
||||||
- Mint("/watch/foo.mkv", size, mtime) → uuid_Y.
|
|
||||||
- AddAlias(uuid_Y, "midhash256:abc...").
|
|
||||||
- Plugins now process the file, referenced by midhash256:abc...
|
|
||||||
(UUID is never disclosed to them.)
|
|
||||||
|
|
||||||
5. Later, the full-hash plugin computes sha256.
|
|
||||||
- Plugin reports back: "for CID midhash256:abc..., sha256 is sha256:def...".
|
|
||||||
- meta-core resolves midhash256:abc → uuid_Y via GetByCID.
|
|
||||||
- meta-core calls AddAlias(uuid_Y, "sha256:def...").
|
|
||||||
- Nothing else to do: the next read of this entry derives a canonical CID
|
|
||||||
of sha2-256, because it outranks the midhash.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Lifecycle: file deletion
|
A second path with the same midhash is just another `local_file` row: same
|
||||||
|
cid, same group, same record; the record's `filePath` is the
|
||||||
|
lexicographically first of the paths.
|
||||||
|
|
||||||
```
|
### File deletion
|
||||||
1. Watcher sees /watch/foo.mkv deleted.
|
|
||||||
2. Resolve which entry owns this path:
|
The watcher removes the path's `local_file` row; the record view loses that
|
||||||
a. If /watch/foo.mkv is in some entry's duplicates set:
|
`filePath`. Claims are signed truth from their writers and are not deleted
|
||||||
- Remove from set. Done.
|
by meta-core (the store is permanent and additive,
|
||||||
b. If /watch/foo.mkv is the filePath of some entry:
|
`docs/project-architecture/metacore-as-permanent-store.md`).
|
||||||
- If duplicates is non-empty: promote one duplicate to filePath.
|
|
||||||
- If duplicates is empty: DeleteRoot(uuid).
|
*(v1.x)* Deletion promoted a duplicate to `filePath`, or called `DeleteRoot`,
|
||||||
- Iterates the cids set, deletes each cid:<cid> reverse-index key.
|
which removed every `cid:*` alias and every `file:<uuid>/*` key.
|
||||||
- Deletes every file:<uuid>/* key.
|
|
||||||
- Removes uuid from file:__index__.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/meta/<cid>
|
GET /api/meta/<cid> → 200 merged document { "filePath", "canonical_cid", "cids": [...], ... }
|
||||||
→ 200 { "filePath": "...", "canonical_cid": "...",
|
(optional ?as=<uid>: the viewer's own votes win)
|
||||||
"cids": [...], "title": "...", "tmdb": {...}, ... }
|
→ 404 if no such cid
|
||||||
→ 404 if no such CID
|
GET /api/meta/<cid>/claims → {rid, cids, claims}: every live signed claim of the cid's
|
||||||
|
group, all signers (?key=, ?uid=); 404 if no such cid
|
||||||
GET /api/file/<cid> (already exists — implementation now O(1))
|
GET /api/metadata/<rid>/claims → the same, addressed by rid (local consumers)
|
||||||
→ streams file content
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The UUID is **not** included in the response. Clients address everything by
|
The rid is not part of the cid-addressed contract and never appears in a
|
||||||
CID; meta-core's choice of internal identifier remains a private
|
claim. It is visible only to local consumers that list or address records by
|
||||||
implementation detail and may change.
|
rid (`/api/metadata/{rid}`, `/api/metadata/{rid}/claims`, `hash-ids`, SSE event
|
||||||
|
keys; the claims routes also echo it as `rid`), and may change on a
|
||||||
|
group merge (delete event, then re-resolve by cid).
|
||||||
|
|
||||||
Auth: `/api/meta/<cid>` follows the same pattern as `/api/file/<cid>` and
|
Auth: `/api/meta/<cid>` bypasses the perimeter like `/api/file/<cid>` — it is
|
||||||
bypasses the auth perimeter. It's the metadata twin of the content endpoint,
|
the metadata twin of the content endpoint.
|
||||||
and meta-share calls it from federated search responses.
|
|
||||||
|
|
||||||
## Cross-service impact
|
## Cross-service impact (v2.0.0)
|
||||||
|
|
||||||
This is a meta-core schema change. Other services interact only through CIDs,
|
- **Writers** (plugins, self-publishing feeders, gateway core, meta-share,
|
||||||
so the impact is contained but real.
|
meta-search): address claims by cid only and sign them; they never resolve
|
||||||
|
a rid.
|
||||||
|
- **meta-fuse, meta-stremio**: unchanged — merged reads and SSE keep their
|
||||||
|
shape; a group merge reaches them as ordinary `del` + `set` events.
|
||||||
|
- **meta-search / meta-share**: announce the derived canonical cid (each
|
||||||
|
mirrors the Go rank) and relay raw claims; every cid of the group travels as
|
||||||
|
`cids/*` claims, so a peer querying by any digest gets a hit.
|
||||||
|
|
||||||
- **meta-sort**: writers must resolve plugin callbacks to a UUID before
|
## Migration (history)
|
||||||
writing. The existing `LeaderClient` gains a `getByCID` cache.
|
|
||||||
- **meta-fuse**: VFS rebuild already iterates `file:__index__`; only the
|
|
||||||
ID format changes.
|
|
||||||
- **meta-stremio**: queries by CID; needs no logic change beyond its storage
|
|
||||||
client.
|
|
||||||
- **meta-dup**: gains a cleaner data source — duplicates are now explicit in
|
|
||||||
the `duplicates` set, not implicit in colliding writes.
|
|
||||||
- **meta-search / meta-share**: announce the *derived* canonical CID rather
|
|
||||||
than the midhash — each mirrors the Go rank in `ingest::canonical_cid_from_metadata`
|
|
||||||
rather than reading a stored field. Federated search responses include all
|
|
||||||
CIDs from the `cids` key-set so any peer querying by any digest gets a hit.
|
|
||||||
(Written before the 2026-06 discovery/transport split; discovery is now
|
|
||||||
meta-search's job.)
|
|
||||||
- **Plugins**: no change. They speak CIDs only.
|
|
||||||
|
|
||||||
## Migration
|
> **The v1.0 sweep below was never run.** The alpha shipped behind a
|
||||||
|
> clean-wipe gate. The v1.x → v2.0.0 conversion of watch.nsl.sh is a
|
||||||
|
> one-off tool outside the service image, `packages/meta-core/tools/claims-migrate`
|
||||||
|
> (canonical cid by rank per record; `cid:` aliases → `cid_group`, rids keep
|
||||||
|
> their UUIDs, and the `cid:` index wins when a record's `cids/*` member
|
||||||
|
> disagrees; fields re-signed by a throwaway legacy key); every other box
|
||||||
|
> starts empty. Implementation basis §11.
|
||||||
|
|
||||||
> **Historical — this sweep was never run.** The alpha shipped behind a
|
*(proposal)* One-time sweep over `file:__index__`: mint a uuid, `AddAlias`
|
||||||
> clean-wipe gate (Redis was flushed and the library re-ingested), so no
|
the old hashID and every `cid_*` field, `RENAME` every property under the new
|
||||||
> in-place migration code exists. Kept for the record.
|
root, reconcile canonical, swap the index entry; crash-recoverable by
|
||||||
|
detecting which step completed.
|
||||||
One-time sweep, idempotent, runs on first boot after upgrade:
|
|
||||||
|
|
||||||
```
|
|
||||||
For each hashID in file:__index__ (current shape: midhash256:abc, sha256:def, …):
|
|
||||||
1. Mint a fresh uuid_Y.
|
|
||||||
2. AddAlias(uuid_Y, hashID). The existing primary becomes an alias.
|
|
||||||
3. For each cid_* field on the old entry, AddAlias(uuid_Y, that value).
|
|
||||||
4. RENAME file:<hashID>/<property> → file:<uuid_Y>/<property> for every
|
|
||||||
property (Lua script, one entry at a time; non-atomic across entries,
|
|
||||||
but each entry stays internally consistent).
|
|
||||||
5. Reconcile canonical_cid.
|
|
||||||
6. Remove hashID from file:__index__, add uuid_Y.
|
|
||||||
```
|
|
||||||
|
|
||||||
Crash-recoverable: re-running the sweep finds entries with both old and new
|
|
||||||
roots and resumes by detecting which step completed (presence of
|
|
||||||
`file:<uuid>/cids` indicates step 3 finished; absence of any
|
|
||||||
`file:<hashID>/*` keys indicates step 4 finished).
|
|
||||||
|
|
||||||
## Decisions recorded
|
## Decisions recorded
|
||||||
|
|
||||||
| Question | Decision | Notes |
|
| Question | Decision | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| When is the UUID minted? | At first-hash | Pragmatic for the initial migration. Long-term target: at discovery, fully decoupled from hashing. Tracked as an open question below. |
|
| When is the rid minted? | When a cid is first seen with no link to a known group | v1.x: at first hash of a watched file. Now any first-seen cid (watcher midhash or a claim about a new cid) mints one |
|
||||||
| UUID format | UUIDv7, 26-char Crockford Base32 | Time-sortable, 128-bit, distributed-safe. |
|
| Rid format | UUIDv7, 26-char Crockford Base32 | Time-sortable, 128-bit, distributed-safe |
|
||||||
| UUID visible externally? | No | Pure meta-core internal. Clients address by CID only. |
|
| Rid visible externally? | No — never signed, never on the swarm/gateway wire | Local HTTP consumers may hold it |
|
||||||
| Merge semantics | One UUID per content; additional paths into `duplicates` | One file (logical) = one metadata set. Multiple physical paths supported via `duplicates`. |
|
| What do signed votes name? | A cid | Every peer agrees on a cid; rids differ per box |
|
||||||
| Canonical CID rank | IPFS > sha-family > btih > midhash | Utility-weighted, not strength-weighted. |
|
| Record identity | A group of cids (`cid_group`, in truth) | Linked by `cids/<other>` votes |
|
||||||
| ~~`cids` and `duplicates` field type~~ | ~~Redis SET~~ → **`cids` is a key-set of bare CIDs; `duplicates` stayed a SET** | Reversed before shipping. Flat `cids/<cid>` STRING keys merge conflict-free across peers; a SET does not. See §14.13 of `METADATA_KEYS.md`. |
|
| Group merge | Older rid survives; losing rid → `del` events | No `rid_alias` (out of scope for phase 1) |
|
||||||
| ~~Stored `canonical_cid`~~ | **Dropped — derived on read** | A stored scalar is not conflict-free to merge. `GetMetadataDocument` ranks the `cids` members per request. |
|
| Duplicate paths | `local_file` rows sharing a midhash (cache) | v1.x: `duplicates` SET, then `dup:<uuid>/<path>` |
|
||||||
| Reverse-index format | Flat `cid:<cid>` STRING keys | Simple, scan-friendly, no hot-key contention. |
|
| Canonical CID rank | IPFS > sha-family > btih > midhash, derived on read | Utility-weighted; never stored |
|
||||||
|
| `cids` shape | Key-set of bare CIDs, one claim per member | Merges with the generic rule; a SET would not |
|
||||||
|
| Reverse index | `cid_group(cid PK → rid)` in truth, copied to cache `cid:<cid>` rows for reads | v1.x: flat `cid:<cid>` keys were the only copy |
|
||||||
|
| Unlinking | A retracted `cids/*` link does not split a group | Phase 1 |
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
- **Discovery-time UUID minting.** The cleaner long-term model: watcher mints
|
- **Discovery-time rid minting.** Minting at discovery (before hashing)
|
||||||
a UUID at discovery (before hashing), then `AddAlias` runs when the midhash
|
would give two same-content paths two rids until hashing links them. The
|
||||||
arrives. Requires an explicit "reconcile if alias already exists" step
|
claims model already handles that case — it is an ordinary group merge —
|
||||||
because two paths with the same content would each get their own UUID until
|
so this is now a policy choice, not a missing mechanism.
|
||||||
hashing reveals the collision. Worth doing eventually; not required for
|
- **IPFS gateway endpoint.** Should meta-core expose `/ipfs/<cid>` for
|
||||||
this iteration.
|
external IPFS clients? Adjacent, not part of this design.
|
||||||
|
- ~~**meta-share announce strategy.**~~ Settled: the canonical cid addresses
|
||||||
- ~~**meta-share announce strategy.**~~ **Settled.** The record carries *every*
|
the record, every group cid travels as a `cids/*` claim.
|
||||||
member of `cids` on the wire (`Record.cids`) and addresses itself by the
|
- ~~**Keyspace-notification consumers.**~~ Obsolete since Redis left.
|
||||||
single derived canonical (`Record.cid`), so a peer querying by any digest
|
|
||||||
gets a hit without the DHT footprint of announcing each one separately.
|
|
||||||
|
|
||||||
- **IPFS gateway endpoint.** Should meta-core expose `/ipfs/<cid>` as a
|
|
||||||
content-streaming endpoint for external IPFS clients? Adjacent to this
|
|
||||||
design but not part of it.
|
|
||||||
|
|
||||||
- **Keyspace-notification consumers.** Today's parsers (`schema/indexer.go`)
|
|
||||||
handle `file:<type>:<hash>/...` with a special split rule for the colon
|
|
||||||
inside `midhash256:abc`. With UUIDs (no colons), `parseFieldPath` simplifies
|
|
||||||
— but any external consumer subscribed by hashID pattern needs updating.
|
|
||||||
Inventory those before flipping the migration.
|
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
- **Not** a content-replication or IPFS-pinning system. Registering an IPFS
|
- **Not** a content-replication or pinning system. A `cids/<ipfs-cid>` vote
|
||||||
CID alias does not pin the file anywhere — it only states "if somebody
|
pins nothing; it states "hashing these bytes with IPFS's chunker gives this
|
||||||
hashes this file with IPFS's chunker, this is the CID they'd get."
|
CID."
|
||||||
- **Not** a versioning scheme. Metadata is a snapshot of current state;
|
- **Not** a history. `ts` versions each `(uid, cid, key)` and the newest vote
|
||||||
historical versions are out of scope.
|
replaces the older one; only tombstones are kept, to block replay.
|
||||||
- **Not** a strong-consistency system. Reverse-index writes happen under the
|
- **Not** strongly consistent across the two databases: the cache is applied
|
||||||
leader lock; reads from followers may briefly miss a freshly-added alias.
|
after the truth commit. `POST /api/claims` waits for the apply before
|
||||||
|
answering, so the writer reads its own write; another reader may briefly
|
||||||
|
miss a just-accepted claim. It is behind, never wrong.
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Signed claims (meta-core v2.0.0). The editor no longer writes keys: an edit
|
||||||
|
// is a claim signed as the chosen identity account — through meta-core's
|
||||||
|
// account-key service (/api/identity/sign, compact low-S) — and posted to
|
||||||
|
// POST /api/claims. That vote wins in that account's view; the shared merged
|
||||||
|
// value only moves if enough signers agree.
|
||||||
|
//
|
||||||
|
// MMUR2 bytes (must match internal/claim and its golden vectors):
|
||||||
|
// "MMUR2\n" || canonical CBOR [cid, uid, key, value, ts]
|
||||||
|
|
||||||
|
export interface Claim {
|
||||||
|
cid: string;
|
||||||
|
uid: string;
|
||||||
|
key: string;
|
||||||
|
value: string | { b64: string } | null;
|
||||||
|
ts: number;
|
||||||
|
sig: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimsResponse {
|
||||||
|
rid: string;
|
||||||
|
cids: string[];
|
||||||
|
claims: Claim[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
uid: string;
|
||||||
|
curve: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNER_LS_KEY = 'kv-editor.signer.v1';
|
||||||
|
|
||||||
|
async function jsonOrThrow<T>(res: Response): Promise<T> {
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
if (body?.message) msg = body.message;
|
||||||
|
else if (body?.error) msg = body.error;
|
||||||
|
} catch {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- canonical CBOR for the five MMUR2 shapes -------------------------------
|
||||||
|
|
||||||
|
function head(out: number[], major: number, n: number) {
|
||||||
|
const m = major << 5;
|
||||||
|
if (n < 24) out.push(m | n);
|
||||||
|
else if (n <= 0xff) out.push(m | 24, n);
|
||||||
|
else if (n <= 0xffff) out.push(m | 25, n >> 8, n & 0xff);
|
||||||
|
else if (n <= 0xffffffff) out.push(m | 26, (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
|
||||||
|
else {
|
||||||
|
const hi = Math.floor(n / 2 ** 32);
|
||||||
|
const lo = n >>> 0;
|
||||||
|
out.push(m | 27, (hi >>> 24) & 0xff, (hi >>> 16) & 0xff, (hi >>> 8) & 0xff, hi & 0xff,
|
||||||
|
(lo >>> 24) & 0xff, (lo >>> 16) & 0xff, (lo >>> 8) & 0xff, lo & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(out: number[], s: string) {
|
||||||
|
const b = new TextEncoder().encode(s);
|
||||||
|
head(out, 3, b.length);
|
||||||
|
for (const x of b) out.push(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signedBytes(cid: string, uid: string, key: string, value: string | null, ts: number): Uint8Array {
|
||||||
|
const out: number[] = [...new TextEncoder().encode('MMUR2\n')];
|
||||||
|
head(out, 4, 5);
|
||||||
|
text(out, cid);
|
||||||
|
text(out, uid);
|
||||||
|
text(out, key);
|
||||||
|
if (value === null) out.push(0xf6);
|
||||||
|
else text(out, value);
|
||||||
|
head(out, 0, ts);
|
||||||
|
return new Uint8Array(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64(bytes: Uint8Array): string {
|
||||||
|
let s = '';
|
||||||
|
for (const x of bytes) s += String.fromCharCode(x);
|
||||||
|
return btoa(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- signer -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getSigner(): string {
|
||||||
|
try { return localStorage.getItem(SIGNER_LS_KEY) ?? ''; } catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSigner(uid: string) {
|
||||||
|
try { localStorage.setItem(SIGNER_LS_KEY, uid); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClaimsAPI = {
|
||||||
|
async accounts(): Promise<Account[]> {
|
||||||
|
const r = await jsonOrThrow<{ accounts: Account[] }>(await fetch('/api/identity/accounts'));
|
||||||
|
return r.accounts ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async forRecord(rid: string, key?: string): Promise<ClaimsResponse> {
|
||||||
|
const q = key ? `?key=${encodeURIComponent(key)}` : '';
|
||||||
|
return jsonOrThrow(await fetch(`/api/metadata/${encodeURIComponent(rid)}/claims${q}`));
|
||||||
|
},
|
||||||
|
|
||||||
|
// vote signs value (null = retraction) for key on the record's subject cid
|
||||||
|
// as the chosen account and posts it.
|
||||||
|
async vote(rid: string, key: string, value: string | null): Promise<void> {
|
||||||
|
const uid = getSigner();
|
||||||
|
if (!uid) throw new Error('choose a signing account first (top right)');
|
||||||
|
const rec = await this.forRecord(rid);
|
||||||
|
const cid = rec.cids[0];
|
||||||
|
if (!cid) throw new Error(`record ${rid} has no cid`);
|
||||||
|
// ts must beat this account's previous vote on the key.
|
||||||
|
const prev = rec.claims.filter((c) => c.uid === uid && c.key === key).map((c) => c.ts);
|
||||||
|
const ts = Math.max(Date.now(), ...prev.map((t) => t + 1));
|
||||||
|
const bytes = signedBytes(cid, uid, key, value, ts);
|
||||||
|
const signed = await jsonOrThrow<{ sigB64: string }>(await fetch('/api/identity/sign', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ uid, bytesB64: b64(bytes), format: 'compact' }),
|
||||||
|
}));
|
||||||
|
const claim: Claim = { cid, uid, key, value, ts, sig: signed.sigB64 };
|
||||||
|
const res = await jsonOrThrow<{ status: string; reason?: string }[]>(await fetch('/api/claims', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify([claim]),
|
||||||
|
}));
|
||||||
|
const st = res[0]?.status;
|
||||||
|
if (st !== 'accepted' && st !== 'unchanged') {
|
||||||
|
throw new Error(`claim ${st}${res[0]?.reason ? ': ' + res[0].reason : ''}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
+135
-12
@@ -1,8 +1,17 @@
|
|||||||
// Client for the meta-core /api/kv endpoints. The keyspace is a slash-tree of
|
// The editor's key tree. Since meta-core v2.0.0 the records are no longer raw
|
||||||
// string-valued keys; this module wraps the S3-style listing, search, and
|
// keys: `file:<rid>/<field>` is the MERGED view, derived from signed claims.
|
||||||
// per-key get/put/delete operations.
|
// This module keeps the slash-tree the components were built on, as a view:
|
||||||
|
//
|
||||||
|
// - reads of `file:` keys come from the merged record API
|
||||||
|
// (/api/metadata/hash-ids, /api/metadata/{rid});
|
||||||
|
// - writes of `file:` keys are signed claims (claimsApi.ts): put = a vote,
|
||||||
|
// del = a retraction of the chosen account's vote;
|
||||||
|
// - every other key is service scratch KV, through /api/kv/*.
|
||||||
|
|
||||||
|
import { ClaimsAPI } from './claimsApi';
|
||||||
|
|
||||||
const API_BASE = '/api/kv';
|
const API_BASE = '/api/kv';
|
||||||
|
const RECORD_NS = 'file:';
|
||||||
|
|
||||||
export interface KVTreeResponse {
|
export interface KVTreeResponse {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
@@ -61,6 +70,45 @@ async function jsonOrThrow<T>(res: Response): Promise<T> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitRecordKey decomposes file:<rid>/<field> (field may be empty).
|
||||||
|
export function splitRecordKey(key: string): { rid: string; field: string } | null {
|
||||||
|
if (!key.startsWith(RECORD_NS)) return null;
|
||||||
|
const rest = key.slice(RECORD_NS.length);
|
||||||
|
const slash = rest.indexOf('/');
|
||||||
|
if (slash <= 0) return null;
|
||||||
|
return { rid: rest.slice(0, slash), field: rest.slice(slash + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let hashIdsCache: { at: number; ids: string[] } | null = null;
|
||||||
|
|
||||||
|
async function hashIds(): Promise<string[]> {
|
||||||
|
if (hashIdsCache && Date.now() - hashIdsCache.at < 5000) return hashIdsCache.ids;
|
||||||
|
const r = await jsonOrThrow<{ hashIds: string[] }>(await fetch('/api/metadata/hash-ids'));
|
||||||
|
hashIdsCache = { at: Date.now(), ids: (r.hashIds ?? []).slice().sort() };
|
||||||
|
return hashIdsCache.ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordFields(rid: string): Promise<Record<string, string>> {
|
||||||
|
const res = await fetch(`/api/metadata/${encodeURIComponent(rid)}`);
|
||||||
|
if (res.status === 404) return {};
|
||||||
|
const r = await jsonOrThrow<{ metadata: Record<string, string> }>(res);
|
||||||
|
return r.metadata ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// listUnder lists the children of prefix among keys, S3-style.
|
||||||
|
function listUnder(prefix: string, delimiter: string, keys: string[]) {
|
||||||
|
const branches = new Set<string>();
|
||||||
|
const leaves: string[] = [];
|
||||||
|
for (const k of keys) {
|
||||||
|
if (!k.startsWith(prefix)) continue;
|
||||||
|
const rest = k.slice(prefix.length);
|
||||||
|
const i = delimiter ? rest.indexOf(delimiter) : -1;
|
||||||
|
if (i >= 0) branches.add(prefix + rest.slice(0, i + delimiter.length));
|
||||||
|
else leaves.push(k);
|
||||||
|
}
|
||||||
|
return { branches: [...branches].sort(), leaves: leaves.sort() };
|
||||||
|
}
|
||||||
|
|
||||||
export const KVAPI = {
|
export const KVAPI = {
|
||||||
async info(): Promise<KVInfoResponse> {
|
async info(): Promise<KVInfoResponse> {
|
||||||
return jsonOrThrow(await fetch(`${API_BASE}/info`));
|
return jsonOrThrow(await fetch(`${API_BASE}/info`));
|
||||||
@@ -72,17 +120,34 @@ export const KVAPI = {
|
|||||||
cursor?: string;
|
cursor?: string;
|
||||||
max?: number;
|
max?: number;
|
||||||
}): Promise<KVTreeResponse> {
|
}): Promise<KVTreeResponse> {
|
||||||
|
const prefix = params.prefix ?? '';
|
||||||
|
const delimiter = params.delimiter ?? '';
|
||||||
|
const done = (branches: string[], leaves: string[]): KVTreeResponse =>
|
||||||
|
({ prefix, delimiter, branches, leaves, cursor: '0', hasMore: false });
|
||||||
|
|
||||||
|
if (prefix.startsWith(RECORD_NS)) {
|
||||||
|
const parts = splitRecordKey(prefix);
|
||||||
|
if (!parts) {
|
||||||
|
const ids = await hashIds();
|
||||||
|
return done(ids.map((id) => `${RECORD_NS}${id}/`), []);
|
||||||
|
}
|
||||||
|
const fields = await recordFields(parts.rid);
|
||||||
|
const keys = Object.keys(fields).map((f) => `${RECORD_NS}${parts.rid}/${f}`);
|
||||||
|
const l = listUnder(prefix, delimiter, keys);
|
||||||
|
return done(l.branches, l.leaves);
|
||||||
|
}
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (params.prefix !== undefined) q.set('prefix', params.prefix);
|
q.set('prefix', prefix);
|
||||||
if (params.delimiter !== undefined) q.set('delimiter', params.delimiter);
|
q.set('delimiter', delimiter);
|
||||||
if (params.cursor) q.set('cursor', params.cursor);
|
if (params.cursor) q.set('cursor', params.cursor);
|
||||||
if (params.max) q.set('max', String(params.max));
|
if (params.max) q.set('max', String(params.max));
|
||||||
return jsonOrThrow(await fetch(`${API_BASE}/tree?${q}`));
|
const svc = await jsonOrThrow<KVTreeResponse>(await fetch(`${API_BASE}/tree?${q}`));
|
||||||
|
if (prefix === '' && delimiter) {
|
||||||
|
svc.branches = [RECORD_NS, ...svc.branches.filter((b) => b !== RECORD_NS)];
|
||||||
|
}
|
||||||
|
return svc;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Walk multiple SCAN cycles until the keyspace is exhausted or a soft cap
|
|
||||||
// is reached. Useful for branches with up to a few thousand children where
|
|
||||||
// pagination UX would just be a worse spinner.
|
|
||||||
async treeAll(prefix: string, delimiter: string, softCap = 5000): Promise<KVTreeResponse> {
|
async treeAll(prefix: string, delimiter: string, softCap = 5000): Promise<KVTreeResponse> {
|
||||||
const seenLeaves = new Set<string>();
|
const seenLeaves = new Set<string>();
|
||||||
const seenBranches = new Set<string>();
|
const seenBranches = new Set<string>();
|
||||||
@@ -105,22 +170,73 @@ export const KVAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// search finds keys whose path contains the substring: records matched by
|
||||||
|
// free text (each matching field key), plus service keys.
|
||||||
async search(contains: string, limit = 500): Promise<KVSearchResponse> {
|
async search(contains: string, limit = 500): Promise<KVSearchResponse> {
|
||||||
|
const r = await jsonOrThrow<{ results: { hashId: string; metadata: Record<string, string> }[] }>(
|
||||||
|
await fetch('/api/metadata/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query: contains, limit: 100 }),
|
||||||
|
}));
|
||||||
|
const needle = contains.toLowerCase();
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (const hit of r.results ?? []) {
|
||||||
|
for (const [f, v] of Object.entries(hit.metadata)) {
|
||||||
|
const k = `${RECORD_NS}${hit.hashId}/${f}`;
|
||||||
|
if (k.toLowerCase().includes(needle) || v.toLowerCase().includes(needle)) keys.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
const q = new URLSearchParams({ contains, limit: String(limit) });
|
const q = new URLSearchParams({ contains, limit: String(limit) });
|
||||||
return jsonOrThrow(await fetch(`${API_BASE}/search?${q}`));
|
const svc = await jsonOrThrow<KVSearchResponse>(await fetch(`${API_BASE}/search?${q}`));
|
||||||
|
const all = [...keys, ...svc.keys].sort();
|
||||||
|
return { contains, keys: all.slice(0, limit), truncated: all.length > limit || svc.truncated };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// find returns record fields (one of `fields`) whose value contains the
|
||||||
|
// substring, via the merged-record search.
|
||||||
async find(contains: string, fields: string[], limit = 200): Promise<KVFindResponse> {
|
async find(contains: string, fields: string[], limit = 200): Promise<KVFindResponse> {
|
||||||
const q = new URLSearchParams({ contains, fields: fields.join(','), limit: String(limit) });
|
const matches: KVFindMatch[] = [];
|
||||||
return jsonOrThrow(await fetch(`${API_BASE}/find?${q}`));
|
let truncated = false;
|
||||||
|
for (const field of fields) {
|
||||||
|
const r = await jsonOrThrow<{ results: { hashId: string; metadata: Record<string, string> }[] }>(
|
||||||
|
await fetch('/api/metadata/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ filters: [{ property: field, values: [contains] }], limit }),
|
||||||
|
}));
|
||||||
|
for (const hit of r.results ?? []) {
|
||||||
|
const v = hit.metadata[field];
|
||||||
|
if (v === undefined) continue;
|
||||||
|
const entryPath = `${RECORD_NS}${hit.hashId}/`;
|
||||||
|
matches.push({ key: entryPath + field, value: v, entryPath, field });
|
||||||
|
if (matches.length >= limit) { truncated = true; break; }
|
||||||
|
}
|
||||||
|
if (truncated) break;
|
||||||
|
}
|
||||||
|
matches.sort((a, b) => a.key.localeCompare(b.key));
|
||||||
|
return { contains, fields, matches, truncated };
|
||||||
},
|
},
|
||||||
|
|
||||||
async get(key: string): Promise<KVValueResponse> {
|
async get(key: string): Promise<KVValueResponse> {
|
||||||
|
const parts = splitRecordKey(key);
|
||||||
|
if (parts) {
|
||||||
|
const fields = await recordFields(parts.rid);
|
||||||
|
const value = fields[parts.field];
|
||||||
|
return { key, type: 'string', value: value ?? '', exists: value !== undefined };
|
||||||
|
}
|
||||||
const q = new URLSearchParams({ key });
|
const q = new URLSearchParams({ key });
|
||||||
return jsonOrThrow(await fetch(`${API_BASE}/value?${q}`));
|
return jsonOrThrow(await fetch(`${API_BASE}/value?${q}`));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// put on a record key is the chosen account's signed vote.
|
||||||
async put(key: string, value: string): Promise<KVValueResponse> {
|
async put(key: string, value: string): Promise<KVValueResponse> {
|
||||||
|
const parts = splitRecordKey(key);
|
||||||
|
if (parts) {
|
||||||
|
await ClaimsAPI.vote(parts.rid, parts.field, value);
|
||||||
|
hashIdsCache = null;
|
||||||
|
return { key, type: 'string', value, exists: true };
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE}/value`, {
|
const res = await fetch(`${API_BASE}/value`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -129,7 +245,14 @@ export const KVAPI = {
|
|||||||
return jsonOrThrow(res);
|
return jsonOrThrow(res);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// del on a record key retracts the chosen account's vote; the merged value
|
||||||
|
// stays while other signers still support it.
|
||||||
async del(key: string): Promise<{ key: string; deleted: boolean }> {
|
async del(key: string): Promise<{ key: string; deleted: boolean }> {
|
||||||
|
const parts = splitRecordKey(key);
|
||||||
|
if (parts) {
|
||||||
|
await ClaimsAPI.vote(parts.rid, parts.field, null);
|
||||||
|
return { key, deleted: true };
|
||||||
|
}
|
||||||
const q = new URLSearchParams({ key });
|
const q = new URLSearchParams({ key });
|
||||||
return jsonOrThrow(await fetch(`${API_BASE}/value?${q}`, { method: 'DELETE' }));
|
return jsonOrThrow(await fetch(`${API_BASE}/value?${q}`, { method: 'DELETE' }));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
const API_BASE = '/api/snapshot';
|
|
||||||
|
|
||||||
export type ImportMode = 'replace' | 'merge';
|
|
||||||
export type ImportConflict = 'mine' | 'source';
|
|
||||||
|
|
||||||
export interface FileImportResult {
|
|
||||||
hashId: string;
|
|
||||||
added: number;
|
|
||||||
updated: number;
|
|
||||||
unchanged: number;
|
|
||||||
kept: number;
|
|
||||||
deleted: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginOutputResult {
|
|
||||||
written: number;
|
|
||||||
skipped: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportResult {
|
|
||||||
mode: ImportMode;
|
|
||||||
conflict?: ImportConflict;
|
|
||||||
dryRun: boolean;
|
|
||||||
totalFiles: number;
|
|
||||||
filesOk: number;
|
|
||||||
filesFailed: number;
|
|
||||||
files?: FileImportResult[];
|
|
||||||
pluginOutput?: PluginOutputResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WipeResult {
|
|
||||||
metadataDeleted: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SnapshotAPI {
|
|
||||||
/**
|
|
||||||
* Trigger a download of a metadata snapshot ZIP.
|
|
||||||
* Browser handles the download via a synthetic <a> click.
|
|
||||||
*/
|
|
||||||
static downloadExport(includes: { pluginOutput?: boolean } = {}): void {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (includes.pluginOutput) parts.push('plugin-output');
|
|
||||||
const qs = parts.length ? `?include=${parts.join(',')}` : '';
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = `${API_BASE}/export${qs}`;
|
|
||||||
a.rel = 'noopener';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a snapshot ZIP and apply it.
|
|
||||||
* Mode replace deletes and rewrites each CID's properties.
|
|
||||||
* Mode merge preserves untouched fields; conflict picks who wins on overlap.
|
|
||||||
*/
|
|
||||||
static async runImport(
|
|
||||||
file: File,
|
|
||||||
opts: { mode: ImportMode; conflict?: ImportConflict; dryRun?: boolean }
|
|
||||||
): Promise<ImportResult> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set('mode', opts.mode);
|
|
||||||
if (opts.mode === 'merge') {
|
|
||||||
params.set('conflict', opts.conflict ?? 'source');
|
|
||||||
}
|
|
||||||
if (opts.dryRun) {
|
|
||||||
params.set('dry_run', 'true');
|
|
||||||
}
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('snapshot', file);
|
|
||||||
const response = await fetch(`${API_BASE}/import?${params.toString()}`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: fd,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(err.message || err.error || 'Import failed');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wipe Redis metadata. Pass scope=metadata.
|
|
||||||
*/
|
|
||||||
static async wipe(scope: 'metadata'): Promise<WipeResult> {
|
|
||||||
const response = await fetch(`${API_BASE}/wipe?scope=${scope}`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(err.message || err.error || 'Wipe failed');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
.snapshot-panel {
|
|
||||||
max-width: 920px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-panel h2 {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-intro {
|
|
||||||
color: var(--text-muted, #888);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-section {
|
|
||||||
border: 1px solid var(--border, #e0e0e0);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-section.danger {
|
|
||||||
border-color: #d04a4a;
|
|
||||||
background: rgba(208, 74, 74, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-section h3 {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin: 0.75rem 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-file {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-file span {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-muted, #888);
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-fieldset {
|
|
||||||
border: 1px solid var(--border, #e0e0e0);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
min-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-fieldset[disabled] {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-fieldset legend {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-fieldset label {
|
|
||||||
display: block;
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-fieldset input[type='radio'] {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-btn {
|
|
||||||
background: var(--bg-button, #f0f0f0);
|
|
||||||
border: 1px solid var(--border, #c0c0c0);
|
|
||||||
color: var(--text, #222);
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-btn:hover:not(:disabled) {
|
|
||||||
background: var(--bg-button-hover, #e0e0e0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-btn.primary {
|
|
||||||
background: #2a6df4;
|
|
||||||
border-color: #2a6df4;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-btn.primary:hover:not(:disabled) {
|
|
||||||
background: #1a5de4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-btn.danger {
|
|
||||||
background: #d04a4a;
|
|
||||||
border-color: #d04a4a;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-btn.danger:hover:not(:disabled) {
|
|
||||||
background: #b83838;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-error {
|
|
||||||
background: rgba(208, 74, 74, 0.1);
|
|
||||||
color: #b83838;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-info {
|
|
||||||
background: rgba(42, 109, 244, 0.08);
|
|
||||||
color: #1a5de4;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-result {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
border: 1px solid var(--border, #e0e0e0);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-result-summary {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-result-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-result-table th,
|
|
||||||
.snapshot-result-table td {
|
|
||||||
border: 1px solid var(--border, #e0e0e0);
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-result-table th {
|
|
||||||
background: var(--bg-subtle, #f5f5f5);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-result-errors {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-result-errors summary {
|
|
||||||
cursor: pointer;
|
|
||||||
color: #b83838;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-result-errors ul {
|
|
||||||
margin: 0.5rem 0 0;
|
|
||||||
padding-left: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-result-errors code {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-checkbox {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin: 0.5rem 0 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-checkbox input {
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-hint {
|
|
||||||
color: var(--text-muted, #888);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-result-aside {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-muted, #555);
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
SnapshotAPI,
|
|
||||||
ImportMode,
|
|
||||||
ImportConflict,
|
|
||||||
ImportResult,
|
|
||||||
} from '../api/snapshotApi';
|
|
||||||
import './SnapshotPanel.css';
|
|
||||||
|
|
||||||
export function SnapshotPanel() {
|
|
||||||
const [exportPluginOutput, setExportPluginOutput] = useState(false);
|
|
||||||
const [importFile, setImportFile] = useState<File | null>(null);
|
|
||||||
const [importMode, setImportMode] = useState<ImportMode>('merge');
|
|
||||||
const [importConflict, setImportConflict] = useState<ImportConflict>('source');
|
|
||||||
const [importing, setImporting] = useState(false);
|
|
||||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [wipeBusy, setWipeBusy] = useState(false);
|
|
||||||
const [wipeMessage, setWipeMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const runImport = async (dryRun: boolean) => {
|
|
||||||
if (!importFile) {
|
|
||||||
setImportError('Pick a snapshot ZIP first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setImporting(true);
|
|
||||||
setImportError(null);
|
|
||||||
setImportResult(null);
|
|
||||||
try {
|
|
||||||
const result = await SnapshotAPI.runImport(importFile, {
|
|
||||||
mode: importMode,
|
|
||||||
conflict: importMode === 'merge' ? importConflict : undefined,
|
|
||||||
dryRun,
|
|
||||||
});
|
|
||||||
setImportResult(result);
|
|
||||||
} catch (e: any) {
|
|
||||||
setImportError(e.message || String(e));
|
|
||||||
} finally {
|
|
||||||
setImporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const runWipe = async () => {
|
|
||||||
const ok = window.confirm(
|
|
||||||
'This deletes ALL metadata in Redis. Files on disk are not touched, but every manual ' +
|
|
||||||
'edit you made via this editor will be lost. Continue?'
|
|
||||||
);
|
|
||||||
if (!ok) return;
|
|
||||||
setWipeBusy(true);
|
|
||||||
setWipeMessage(null);
|
|
||||||
try {
|
|
||||||
const r = await SnapshotAPI.wipe('metadata');
|
|
||||||
setWipeMessage(`Deleted ${r.metadataDeleted} file metadata entries.`);
|
|
||||||
} catch (e: any) {
|
|
||||||
setWipeMessage(`Failed: ${e.message || e}`);
|
|
||||||
} finally {
|
|
||||||
setWipeBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="snapshot-panel">
|
|
||||||
<h2>Snapshot</h2>
|
|
||||||
<p className="snapshot-intro">
|
|
||||||
Export, import, or wipe the metadata KV store. Snapshots are portable ZIP
|
|
||||||
archives — one nested-JSON file per CID, plus a manifest.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<section className="snapshot-section">
|
|
||||||
<h3>Export</h3>
|
|
||||||
<p>Downloads every CID's metadata as a ZIP. Optional cache files can be added below.</p>
|
|
||||||
<label className="snapshot-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={exportPluginOutput}
|
|
||||||
onChange={(e) => setExportPluginOutput(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
Include plugin output
|
|
||||||
<span className="snapshot-hint">
|
|
||||||
{' '}— TMDB posters/backdrops, extracted subtitles. Adds ~120 MB on a populated stack.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
className="snapshot-btn primary"
|
|
||||||
onClick={() =>
|
|
||||||
SnapshotAPI.downloadExport({ pluginOutput: exportPluginOutput })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Download snapshot.zip
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="snapshot-section">
|
|
||||||
<h3>Import</h3>
|
|
||||||
<div className="snapshot-row">
|
|
||||||
<label className="snapshot-file">
|
|
||||||
<span>Snapshot ZIP</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".zip,application/zip"
|
|
||||||
onChange={(e) => {
|
|
||||||
setImportFile(e.target.files?.[0] ?? null);
|
|
||||||
setImportResult(null);
|
|
||||||
setImportError(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="snapshot-row">
|
|
||||||
<fieldset className="snapshot-fieldset">
|
|
||||||
<legend>Mode</legend>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="mode"
|
|
||||||
value="merge"
|
|
||||||
checked={importMode === 'merge'}
|
|
||||||
onChange={() => setImportMode('merge')}
|
|
||||||
/>
|
|
||||||
Merge — keep existing fields not in the snapshot
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="mode"
|
|
||||||
value="replace"
|
|
||||||
checked={importMode === 'replace'}
|
|
||||||
onChange={() => setImportMode('replace')}
|
|
||||||
/>
|
|
||||||
Replace — wipe each imported CID and rewrite from snapshot
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset className="snapshot-fieldset" disabled={importMode !== 'merge'}>
|
|
||||||
<legend>Conflict</legend>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="conflict"
|
|
||||||
value="source"
|
|
||||||
checked={importConflict === 'source'}
|
|
||||||
onChange={() => setImportConflict('source')}
|
|
||||||
/>
|
|
||||||
Source wins (overwrite my edits)
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="conflict"
|
|
||||||
value="mine"
|
|
||||||
checked={importConflict === 'mine'}
|
|
||||||
onChange={() => setImportConflict('mine')}
|
|
||||||
/>
|
|
||||||
Mine wins (keep my edits, fill in only missing fields)
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="snapshot-row">
|
|
||||||
<button
|
|
||||||
className="snapshot-btn"
|
|
||||||
disabled={!importFile || importing}
|
|
||||||
onClick={() => runImport(true)}
|
|
||||||
>
|
|
||||||
Dry run
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="snapshot-btn primary"
|
|
||||||
disabled={!importFile || importing}
|
|
||||||
onClick={() => runImport(false)}
|
|
||||||
>
|
|
||||||
{importing ? 'Importing…' : 'Apply import'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{importError && <div className="snapshot-error">{importError}</div>}
|
|
||||||
{importResult && <ImportResultView result={importResult} />}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="snapshot-section danger">
|
|
||||||
<h3>Wipe metadata</h3>
|
|
||||||
<p>
|
|
||||||
Deletes every <code>file:*</code> key plus <code>file:__index__</code>.
|
|
||||||
Files on disk are untouched.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
className="snapshot-btn danger"
|
|
||||||
disabled={wipeBusy}
|
|
||||||
onClick={runWipe}
|
|
||||||
>
|
|
||||||
{wipeBusy ? 'Wiping…' : 'Wipe all metadata'}
|
|
||||||
</button>
|
|
||||||
{wipeMessage && <div className="snapshot-info">{wipeMessage}</div>}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImportResultView({ result }: { result: ImportResult }) {
|
|
||||||
const totals = (result.files ?? []).reduce(
|
|
||||||
(acc, f) => {
|
|
||||||
acc.added += f.added;
|
|
||||||
acc.updated += f.updated;
|
|
||||||
acc.unchanged += f.unchanged;
|
|
||||||
acc.kept += f.kept;
|
|
||||||
acc.deleted += f.deleted;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ added: 0, updated: 0, unchanged: 0, kept: 0, deleted: 0 }
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="snapshot-result">
|
|
||||||
<div className="snapshot-result-summary">
|
|
||||||
<strong>{result.dryRun ? 'Dry run preview' : 'Import applied'}</strong>
|
|
||||||
<span>
|
|
||||||
{result.filesOk} ok / {result.filesFailed} failed of {result.totalFiles} files
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
mode={result.mode}
|
|
||||||
{result.conflict ? `, conflict=${result.conflict}` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<table className="snapshot-result-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>added</th>
|
|
||||||
<th>updated</th>
|
|
||||||
<th>unchanged</th>
|
|
||||||
<th>kept (mine)</th>
|
|
||||||
<th>deleted (replace)</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>{totals.added}</td>
|
|
||||||
<td>{totals.updated}</td>
|
|
||||||
<td>{totals.unchanged}</td>
|
|
||||||
<td>{totals.kept}</td>
|
|
||||||
<td>{totals.deleted}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{result.pluginOutput && (
|
|
||||||
<div className="snapshot-result-aside">
|
|
||||||
plugin-output: {result.pluginOutput.written} written
|
|
||||||
{result.pluginOutput.skipped > 0
|
|
||||||
? `, ${result.pluginOutput.skipped} skipped (existed)`
|
|
||||||
: ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{result.files && result.files.some((f) => f.error) && (
|
|
||||||
<details className="snapshot-result-errors">
|
|
||||||
<summary>Per-file errors</summary>
|
|
||||||
<ul>
|
|
||||||
{result.files
|
|
||||||
.filter((f) => f.error)
|
|
||||||
.map((f) => (
|
|
||||||
<li key={f.hashId}>
|
|
||||||
<code>{f.hashId}</code>: {f.error}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Claim, ClaimsAPI, getSigner } from '../api/claimsApi';
|
||||||
|
import { splitRecordKey } from '../api/kvApi';
|
||||||
|
|
||||||
|
// ClaimsPanel lists the signed claims behind one merged field: who said what,
|
||||||
|
// and when. The merged value is what most signers agree on.
|
||||||
|
export function ClaimsPanel({ recordKey, reloadToken }: { recordKey: string; reloadToken: number }) {
|
||||||
|
const [claims, setClaims] = useState<Claim[] | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const parts = splitRecordKey(recordKey);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!parts || !parts.field) return;
|
||||||
|
let cancelled = false;
|
||||||
|
ClaimsAPI.forRecord(parts.rid, parts.field)
|
||||||
|
.then((r) => { if (!cancelled) setClaims(r.claims); })
|
||||||
|
.catch((e) => { if (!cancelled) setError(e.message); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [recordKey, reloadToken]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
if (!parts || !parts.field) return null;
|
||||||
|
if (error) return <div className="kv-claims kv-pane-status">claims: {error}</div>;
|
||||||
|
if (!claims) return <div className="kv-claims kv-pane-status">loading claims…</div>;
|
||||||
|
const me = getSigner();
|
||||||
|
return (
|
||||||
|
<div className="kv-claims">
|
||||||
|
<div className="kv-leaf-label">Claims ({claims.length})</div>
|
||||||
|
{claims.length === 0 ? (
|
||||||
|
<div className="kv-pane-hint">No signed claim — a box-local fact (file path, size, mtime) or a group member.</div>
|
||||||
|
) : (
|
||||||
|
<table className="kv-claims-table">
|
||||||
|
<tbody>
|
||||||
|
{claims.map((c) => (
|
||||||
|
<tr key={c.uid + c.cid}>
|
||||||
|
<td title={c.uid}><code>{c.uid.slice(0, 10)}…</code>{c.uid === me ? ' (you)' : ''}</td>
|
||||||
|
<td>{c.value === null ? <em>retracted</em> : typeof c.value === 'string' ? c.value : <em>ciphertext</em>}</td>
|
||||||
|
<td title={new Date(c.ts).toISOString()}>{new Date(c.ts).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -894,3 +894,9 @@
|
|||||||
.kv-modal-close:hover {
|
.kv-modal-close:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kv-signer { font-size: 12px; display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.kv-signer select { font-size: 12px; }
|
||||||
|
.kv-claims { margin-top: 16px; }
|
||||||
|
.kv-claims-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.kv-claims-table td { padding: 3px 6px; border-top: 1px solid rgba(127, 127, 127, 0.25); vertical-align: top; }
|
||||||
|
|||||||
+2
-32
@@ -10,7 +10,7 @@ const FILE_BRANCH_RE = /^file:[^/]+\/$/;
|
|||||||
import { SearchFieldChips } from './SearchFieldChips';
|
import { SearchFieldChips } from './SearchFieldChips';
|
||||||
import { ToastStack, makeToast } from './Toast';
|
import { ToastStack, makeToast } from './Toast';
|
||||||
import { ToastEntry } from './types';
|
import { ToastEntry } from './types';
|
||||||
import { SnapshotPanel } from '../components/SnapshotPanel';
|
import { SignerPicker } from './SignerPicker';
|
||||||
import './KVApp.css';
|
import './KVApp.css';
|
||||||
|
|
||||||
const SEARCH_FIELDS_LS_KEY = 'kv-editor.searchFields.v1';
|
const SEARCH_FIELDS_LS_KEY = 'kv-editor.searchFields.v1';
|
||||||
@@ -32,7 +32,6 @@ function loadSearchFields(): string[] {
|
|||||||
//
|
//
|
||||||
// A sticky header carries the search bar, the breadcrumb, and basic stats.
|
// A sticky header carries the search bar, the breadcrumb, and basic stats.
|
||||||
export default function KVApp() {
|
export default function KVApp() {
|
||||||
const [snapshotOpen, setSnapshotOpen] = useState(false);
|
|
||||||
// Selected node. branchPath is set when a branch is selected; selectedKey
|
// Selected node. branchPath is set when a branch is selected; selectedKey
|
||||||
// when a leaf is. Mutually exclusive.
|
// when a leaf is. Mutually exclusive.
|
||||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
@@ -176,14 +175,7 @@ export default function KVApp() {
|
|||||||
onClick={() => setReloadToken((n) => n + 1)}
|
onClick={() => setReloadToken((n) => n + 1)}
|
||||||
title="refresh tree"
|
title="refresh tree"
|
||||||
>Refresh</button>
|
>Refresh</button>
|
||||||
<button
|
<SignerPicker onError={(msg) => pushToast(makeToast('error', msg))} />
|
||||||
className="kv-btn-icon kv-app-gear"
|
|
||||||
onClick={() => setSnapshotOpen(true)}
|
|
||||||
title="Snapshot — export / import / wipe"
|
|
||||||
aria-label="Open snapshot settings"
|
|
||||||
>
|
|
||||||
⚙
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<SearchFieldChips fields={searchFields} onChange={setSearchFields} />
|
<SearchFieldChips fields={searchFields} onChange={setSearchFields} />
|
||||||
{breadcrumb && breadcrumb.length > 0 && (
|
{breadcrumb && breadcrumb.length > 0 && (
|
||||||
@@ -284,28 +276,6 @@ export default function KVApp() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{snapshotOpen && (
|
|
||||||
<div
|
|
||||||
className="kv-modal-backdrop"
|
|
||||||
onClick={() => setSnapshotOpen(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="kv-modal"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="kv-modal-close"
|
|
||||||
onClick={() => setSnapshotOpen(false)}
|
|
||||||
aria-label="Close"
|
|
||||||
title="Close"
|
|
||||||
>×</button>
|
|
||||||
<SnapshotPanel />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ToastStack toasts={toasts} onDismiss={dismissToast} />
|
<ToastStack toasts={toasts} onDismiss={dismissToast} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { ALL_TYPES, describeType, detectType } from './typeHeuristics';
|
|||||||
import { lookupSchemaForKey } from './schemaLookup';
|
import { lookupSchemaForKey } from './schemaLookup';
|
||||||
import { LeafValueEditor } from './leafRenderers';
|
import { LeafValueEditor } from './leafRenderers';
|
||||||
import { FieldType } from './types';
|
import { FieldType } from './types';
|
||||||
|
import { ClaimsPanel } from './ClaimsPanel';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// Full Redis key path of the selected leaf.
|
// Full key path of the selected leaf (file:<rid>/<field> = merged view).
|
||||||
selectedKey: string;
|
selectedKey: string;
|
||||||
// Bumped after a delete or external change to force refetch.
|
// Bumped after a delete or external change to force refetch.
|
||||||
reloadToken: number;
|
reloadToken: number;
|
||||||
@@ -71,7 +72,7 @@ export function KVLeafEditor({ selectedKey, reloadToken, onSaved, onDeleted, onE
|
|||||||
};
|
};
|
||||||
|
|
||||||
const del = async () => {
|
const del = async () => {
|
||||||
if (!confirm(`Delete ${selectedKey}?`)) return;
|
if (!confirm(`Retract your vote on ${selectedKey}? Other signers' claims keep the merged value.`)) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await KVAPI.del(selectedKey);
|
await KVAPI.del(selectedKey);
|
||||||
@@ -136,9 +137,11 @@ export function KVLeafEditor({ selectedKey, reloadToken, onSaved, onDeleted, onE
|
|||||||
className="kv-btn kv-btn-danger"
|
className="kv-btn kv-btn-danger"
|
||||||
onClick={del}
|
onClick={del}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>Delete</button>
|
>Retract</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ClaimsPanel recordKey={selectedKey} reloadToken={reloadToken} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Account, ClaimsAPI, getSigner, setSigner } from '../api/claimsApi';
|
||||||
|
|
||||||
|
// SignerPicker chooses the identity account whose key signs the editor's
|
||||||
|
// edits. Every save is that account's claim — it wins in that account's view.
|
||||||
|
export function SignerPicker({ onError }: { onError: (msg: string) => void }) {
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
|
const [uid, setUid] = useState(getSigner());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ClaimsAPI.accounts()
|
||||||
|
.then((list) => {
|
||||||
|
setAccounts(list);
|
||||||
|
if (!uid && list.length === 1) {
|
||||||
|
setSigner(list[0].uid);
|
||||||
|
setUid(list[0].uid);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => onError(`accounts: ${e.message}`));
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="kv-signer" title="Edits are claims signed by this account">
|
||||||
|
sign as{' '}
|
||||||
|
<select
|
||||||
|
value={uid}
|
||||||
|
onChange={(e) => { setSigner(e.target.value); setUid(e.target.value); }}
|
||||||
|
>
|
||||||
|
<option value="">— choose an account —</option>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.uid} value={a.uid}>{a.uid.slice(0, 10)}…</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
module github.com/metazla/meta-core
|
module github.com/metazla/meta-core
|
||||||
|
|
||||||
go 1.21
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alicebob/miniredis/v2 v2.31.1
|
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/mr-tron/base58 v1.3.0
|
github.com/mr-tron/base58 v1.3.0
|
||||||
github.com/redis/go-redis/v9 v9.4.0
|
|
||||||
golang.org/x/crypto v0.31.0
|
golang.org/x/crypto v0.31.0
|
||||||
golang.org/x/net v0.21.0
|
golang.org/x/net v0.21.0
|
||||||
|
modernc.org/sqlite v1.60.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/yuin/gopher-lua v1.1.0 // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/sys v0.28.0
|
modernc.org/libc v1.77.1 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.12.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.48.0
|
||||||
|
|||||||
@@ -1,38 +1,62 @@
|
|||||||
github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0=
|
|
||||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
|
|
||||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
|
||||||
github.com/alicebob/miniredis/v2 v2.31.1 h1:7XAt0uUg3DtwEKW5ZAGa+K7FZV2DdKQo5K/6TTnfX8Y=
|
|
||||||
github.com/alicebob/miniredis/v2 v2.31.1/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg=
|
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
|
||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
|
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
|
||||||
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
|
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
|
||||||
github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||||
|
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||||
|
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
|
||||||
|
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
|
||||||
|
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||||
|
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
|
||||||
|
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||||
|
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||||
|
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleMigrateDualRoots triggers the one-shot sweep that re-unifies
|
|
||||||
// stranded midhash-rooted entries into their UUID roots. See
|
|
||||||
// storage.MigrateDualRoots for the data-flow detail and
|
|
||||||
// docs/api-mediated-access.md for the bug background.
|
|
||||||
//
|
|
||||||
// Returns {fixed: N} on success. Safe to call repeatedly — entries already
|
|
||||||
// migrated are skipped on the next pass.
|
|
||||||
func (s *Server) handleMigrateDualRoots(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fixed, err := s.storage.MigrateDualRootsWithTimeout()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"fixed": fixed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleMigrateDomainScreen triggers the one-shot sweep that rewrites the
|
|
||||||
// retired `domain=film|tv` values to `screen` and backfills `workForm` from
|
|
||||||
// `contentKind` (METADATA_KEYS.md §14.17). See
|
|
||||||
// storage.MigrateDomainScreen for why the vocabulary change is paid in the
|
|
||||||
// data rather than with a read-side alias.
|
|
||||||
//
|
|
||||||
// Returns {fixed: N} — records changed, not records examined. Safe to call
|
|
||||||
// repeatedly: a record already on the current vocabulary is skipped.
|
|
||||||
func (s *Server) handleMigrateDomainScreen(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fixed, err := s.storage.MigrateDomainScreenWithTimeout()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"fixed": fixed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSweepLiteratureEchoes removes query echoes stored on literature cards:
|
|
||||||
// `anilistid` on a site card, and `workcid` / position keys on any card
|
|
||||||
// (meta-read docs/reading-model.md §7). See storage.SweepLiteratureEchoes for
|
|
||||||
// why chapters and anchor cards' ids are deliberately left alone.
|
|
||||||
//
|
|
||||||
// Dry-run unless `?apply=true`: unlike the migrations above this one deletes
|
|
||||||
// keys, so a bare call only reports what it would delete. An unparseable
|
|
||||||
// `apply` is a 400 rather than a silent dry run, so a typo cannot pass for a
|
|
||||||
// sweep that ran.
|
|
||||||
//
|
|
||||||
// Returns {success, dryRun, planned, fixed, byKey} — planned/fixed count
|
|
||||||
// records, byKey counts fields per key. Safe to call repeatedly: a swept
|
|
||||||
// record plans nothing on the next pass.
|
|
||||||
func (s *Server) handleSweepLiteratureEchoes(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
apply := false
|
|
||||||
if v := r.URL.Query().Get("apply"); v != "" {
|
|
||||||
parsed, err := strconv.ParseBool(v)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "apply must be true or false")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
apply = parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := s.storage.SweepLiteratureEchoesWithTimeout(apply)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"dryRun": !apply,
|
|
||||||
"planned": res.Planned,
|
|
||||||
"fixed": res.Fixed,
|
|
||||||
"byKey": res.ByKey,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleMigrateUserCids triggers the one-shot sweep that builds
|
|
||||||
// `udl:idx:user:<uid>:cids` for User Data Layer records written before that
|
|
||||||
// index existed. See storage.UDLBackfillUserCids.
|
|
||||||
//
|
|
||||||
// The index is what makes whole-profile export a set read instead of a
|
|
||||||
// keyspace scan, and it is maintained by the upsert script from now on — so
|
|
||||||
// this only ever matters once, for data already on disk. Until it has run, a
|
|
||||||
// profile that predates the index exports as **empty**, which means a
|
|
||||||
// cross-instance recovery reports success having restored nothing.
|
|
||||||
//
|
|
||||||
// Returns {fixed: N} — cids added, not cids examined. Safe to call repeatedly:
|
|
||||||
// a second pass adds nothing.
|
|
||||||
func (s *Server) handleMigrateUserCids(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fixed, err := s.storage.UDLBackfillUserCids()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"fixed": fixed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/alicebob/miniredis/v2"
|
|
||||||
|
|
||||||
"github.com/metazla/meta-core/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newSweepTestServer is a Server over miniredis with one literature card
|
|
||||||
// carrying a copied `workcid`.
|
|
||||||
func newSweepTestServer(t *testing.T) *Server {
|
|
||||||
t.Helper()
|
|
||||||
mr := miniredis.RunT(t)
|
|
||||||
c := storage.NewClient("")
|
|
||||||
if err := c.Connect("redis://" + mr.Addr()); err != nil {
|
|
||||||
t.Fatalf("connect: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = c.Close() })
|
|
||||||
for k, v := range map[string]string{
|
|
||||||
"domain": "literature",
|
|
||||||
"fileType": "card",
|
|
||||||
"workcid": "bagdsaaaua5qw42lmnfzxi3lbnztwcorrga2tonzy",
|
|
||||||
} {
|
|
||||||
if err := c.SetProperty("card-1", k, v); err != nil {
|
|
||||||
t.Fatalf("seed %s: %v", k, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &Server{storage: c}
|
|
||||||
}
|
|
||||||
|
|
||||||
type sweepResponse struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
DryRun bool `json:"dryRun"`
|
|
||||||
Planned int `json:"planned"`
|
|
||||||
Fixed int `json:"fixed"`
|
|
||||||
ByKey map[string]int `json:"byKey"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func postSweep(t *testing.T, s *Server, query string) (*httptest.ResponseRecorder, sweepResponse) {
|
|
||||||
t.Helper()
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/sweep-literature-echoes"+query, nil)
|
|
||||||
s.handleSweepLiteratureEchoes(rr, req)
|
|
||||||
var body sweepResponse
|
|
||||||
if rr.Code == http.StatusOK {
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
|
||||||
t.Fatalf("decode %s: %v", rr.Body.String(), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rr, body
|
|
||||||
}
|
|
||||||
|
|
||||||
// A bare call must never delete: the default is the dry run.
|
|
||||||
func TestSweepLiteratureEchoes_DefaultsToDryRun(t *testing.T) {
|
|
||||||
s := newSweepTestServer(t)
|
|
||||||
|
|
||||||
rr, body := postSweep(t, s, "")
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, body %s", rr.Code, rr.Body.String())
|
|
||||||
}
|
|
||||||
if !body.Success || !body.DryRun || body.Planned != 1 || body.Fixed != 0 || body.ByKey["workcid"] != 1 {
|
|
||||||
t.Errorf("dry run = %+v", body)
|
|
||||||
}
|
|
||||||
if v, _ := s.storage.GetProperty("card-1", "workcid"); v == "" {
|
|
||||||
t.Errorf("dry run deleted workcid")
|
|
||||||
}
|
|
||||||
|
|
||||||
rr, body = postSweep(t, s, "?apply=true")
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, body %s", rr.Code, rr.Body.String())
|
|
||||||
}
|
|
||||||
if body.DryRun || body.Planned != 1 || body.Fixed != 1 || body.ByKey["workcid"] != 1 {
|
|
||||||
t.Errorf("apply = %+v", body)
|
|
||||||
}
|
|
||||||
if v, _ := s.storage.GetProperty("card-1", "workcid"); v != "" {
|
|
||||||
t.Errorf("apply left workcid = %q", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A typo must not pass for a sweep that ran.
|
|
||||||
func TestSweepLiteratureEchoes_RejectsUnparseableApply(t *testing.T) {
|
|
||||||
s := newSweepTestServer(t)
|
|
||||||
|
|
||||||
rr, _ := postSweep(t, s, "?apply=yes-please")
|
|
||||||
if rr.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("status = %d, want 400", rr.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,6 +56,12 @@ func (s *Server) handleGetMetaByCID(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if as := r.URL.Query().Get("as"); as != "" {
|
||||||
|
if flat, err := s.storage.GetMetadataFlatAs(uuid, as); err == nil && flat != nil {
|
||||||
|
doc.Flat = flat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Response envelope mirrors GetMetadataDocument JSON tags (metadata /
|
// Response envelope mirrors GetMetadataDocument JSON tags (metadata /
|
||||||
// cids / canonical_cid / duplicates), with cid added so the caller can
|
// cids / canonical_cid / duplicates), with cid added so the caller can
|
||||||
// correlate when they fan out N lookups. canonical_cid is derived by
|
// correlate when they fan out N lookups. canonical_cid is derived by
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Signed claims (meta-core v2.0.0, docs/implementation/metacore-claims-model.md).
|
||||||
|
//
|
||||||
|
// POST /api/claims the only metadata write path
|
||||||
|
// GET /api/meta/{cid}/claims every live claim of the cid's group
|
||||||
|
// GET /api/metadata/{rid}/claims the same, addressed by rid
|
||||||
|
// POST /api/merge stateless merge of claims the caller holds
|
||||||
|
//
|
||||||
|
// meta-core never signs a claim here: it verifies each one (MMUR2, low-S) and
|
||||||
|
// the signature is the write authentication.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/claim"
|
||||||
|
"github.com/metazla/meta-core/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GoneResponse is the body of every removed unsigned write route.
|
||||||
|
type GoneResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Use string `json:"use"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGone answers the unsigned write routes removed in v2.0.0.
|
||||||
|
func (s *Server) handleGone(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusGone, GoneResponse{Error: "gone", Use: "POST /api/claims"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeClaims accepts a JSON array of claims or {"claims": [...]}.
|
||||||
|
func decodeClaims(r *http.Request) ([]claim.Claim, error) {
|
||||||
|
var raw json.RawMessage
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<20)).Decode(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var claims []claim.Claim
|
||||||
|
if len(raw) > 0 && raw[0] == '{' {
|
||||||
|
var env struct {
|
||||||
|
Claims []claim.Claim `json:"claims"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return env.Claims, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &claims); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePostClaims handles POST /api/claims: one status per claim, in input
|
||||||
|
// order. 200 even when some claims are refused — the statuses say which.
|
||||||
|
func (s *Server) handlePostClaims(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := decodeClaims(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErrorSlug(w, http.StatusBadRequest, "invalid_json", "body must be a JSON array of claims: "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(claims) > storage.MaxClaimsPerBatch {
|
||||||
|
writeErrorSlug(w, http.StatusRequestEntityTooLarge, "too_many_claims", "at most 10000 claims per batch", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results, err := s.storage.WriteClaims(r.Context(), claims)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimsResponse is the body of the claims read routes: the record's rid, the
|
||||||
|
// cids of its group, and every live signed claim on them.
|
||||||
|
type ClaimsResponse struct {
|
||||||
|
RID string `json:"rid"`
|
||||||
|
CIDs []string `json:"cids"`
|
||||||
|
Claims []claim.Claim `json:"claims"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleClaimsByCID handles GET /api/meta/{cid}/claims[?key=…].
|
||||||
|
func (s *Server) handleClaimsByCID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cid := mux.Vars(r)["cid"]
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rid, err := s.storage.GetByCID(cid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rid == "" {
|
||||||
|
writeErrorSlug(w, http.StatusNotFound, ErrUnknownCID, "no record for this CID", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeClaims(w, r, rid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleClaimsByRID handles GET /api/metadata/{rid}/claims[?key=…]. The rid
|
||||||
|
// may also be a cid (resolved like every /api/metadata read).
|
||||||
|
func (s *Server) handleClaimsByRID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rid := s.storage.ResolveRoot(mux.Vars(r)["hashId"])
|
||||||
|
s.writeClaims(w, r, rid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeClaims answers both claims routes: ?key= keeps one exact key, ?uid=
|
||||||
|
// one signer; 404 when the record does not exist.
|
||||||
|
func (s *Server) writeClaims(w http.ResponseWriter, r *http.Request, rid string) {
|
||||||
|
cids, claims, err := s.storage.RecordClaims(rid, r.URL.Query().Get("key"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(cids) == 0 {
|
||||||
|
writeErrorSlug(w, http.StatusNotFound, ErrUnknownCID, "no record for this rid or cid", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]claim.Claim, 0, len(claims))
|
||||||
|
uid := r.URL.Query().Get("uid")
|
||||||
|
for _, cl := range claims {
|
||||||
|
if uid == "" || cl.UID == uid {
|
||||||
|
out = append(out, cl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, ClaimsResponse{RID: rid, CIDs: cids, Claims: out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeRequest is the body of POST /api/merge.
|
||||||
|
type MergeRequest struct {
|
||||||
|
Claims []claim.Claim `json:"claims"`
|
||||||
|
As string `json:"as,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeResponse is the merged field map, the number of agreeing signers per
|
||||||
|
// key, and how many claims failed verification (dropped, never an error).
|
||||||
|
type MergeResponse struct {
|
||||||
|
Fields map[string]string `json:"fields"`
|
||||||
|
Nsrc map[string]int `json:"nsrc"`
|
||||||
|
Rejected int `json:"rejected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMerge handles POST /api/merge: verify, merge per key with the same
|
||||||
|
// rule the cache uses, overlay `as`'s own votes held by this core. No write.
|
||||||
|
func (s *Server) handleMerge(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req MergeRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<20)).Decode(&req); err != nil {
|
||||||
|
writeErrorSlug(w, http.StatusBadRequest, "invalid_json", err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Claims) > storage.MaxClaimsPerBatch {
|
||||||
|
writeErrorSlug(w, http.StatusRequestEntityTooLarge, "too_many_claims", "at most 10000 claims per merge", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields, nsrc, rejected, err := s.storage.MergeClaims(req.Claims, req.As)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, MergeResponse{Fields: fields, Nsrc: nsrc, Rejected: rejected})
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/claim"
|
||||||
|
"github.com/metazla/meta-core/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func withVars(r *http.Request, vars map[string]string) *http.Request { return mux.SetURLVars(r, vars) }
|
||||||
|
|
||||||
|
// routedServer wires the real route table, so the test sees what clients see.
|
||||||
|
func routedServer(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
s := &Server{storage: newTestStorage(t), router: mux.NewRouter()}
|
||||||
|
s.setupRoutes()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func call(t *testing.T, s *Server, method, path, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.router.ServeHTTP(rr, req)
|
||||||
|
return rr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every unsigned write route of v1.x answers 410 with a pointer to the claims.
|
||||||
|
func TestUnsignedWritesAreGone(t *testing.T) {
|
||||||
|
s := routedServer(t)
|
||||||
|
for _, c := range []struct{ method, path string }{
|
||||||
|
{"PUT", "/meta/bafy"}, {"PATCH", "/meta/bafy"}, {"DELETE", "/meta/bafy"},
|
||||||
|
{"POST", "/meta/bafy/_add/genres"}, {"PUT", "/meta/bafy/title"}, {"DELETE", "/meta/bafy/title"},
|
||||||
|
{"PUT", "/api/metadata/bafy"}, {"DELETE", "/api/metadata/bafy"}, {"PUT", "/api/metadata/bafy/property"},
|
||||||
|
{"POST", "/api/metadata/batch"},
|
||||||
|
} {
|
||||||
|
rr := call(t, s, c.method, c.path, `{"title":"x"}`)
|
||||||
|
if rr.Code != http.StatusGone || !strings.Contains(rr.Body.String(), `"use":"POST /api/claims"`) {
|
||||||
|
t.Errorf("%s %s = %d %s", c.method, c.path, rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rr := call(t, s, "PUT", "/api/kv/value", `{"key":"file:R/title","value":"x"}`); rr.Code != http.StatusConflict {
|
||||||
|
t.Errorf("raw record write via /api/kv/value = %d", rr.Code)
|
||||||
|
}
|
||||||
|
if rr := call(t, s, "PUT", "/api/kv/value", `{"key":"btmagnet:abc","value":"magnet:"}`); rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("service key write = %d %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostClaimsAndReadBack(t *testing.T) {
|
||||||
|
s := routedServer(t)
|
||||||
|
a, b := testSigner(t, 1), testSigner(t, 2)
|
||||||
|
bad := signed(t, b, "bafyA", "year", "1999", 5)
|
||||||
|
bad.Value = claim.Text("2001")
|
||||||
|
body, _ := json.Marshal([]claim.Claim{
|
||||||
|
signed(t, a, "bafyA", "title", "Matrix", 5),
|
||||||
|
signed(t, b, "bafyA", "title", "Matrix", 6),
|
||||||
|
bad,
|
||||||
|
})
|
||||||
|
rr := call(t, s, "POST", "/api/claims", string(body))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST /api/claims = %d %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
var res []storage.ClaimResult
|
||||||
|
_ = json.Unmarshal(rr.Body.Bytes(), &res)
|
||||||
|
if len(res) != 3 || res[0].Status != "accepted" || res[1].Status != "accepted" || res[2].Status != "bad_signature" {
|
||||||
|
t.Fatalf("statuses %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
rr = call(t, s, "GET", "/api/meta/bafyA", "")
|
||||||
|
if !strings.Contains(rr.Body.String(), `"title":"Matrix"`) {
|
||||||
|
t.Fatalf("merged read = %s", rr.Body)
|
||||||
|
}
|
||||||
|
rr = call(t, s, "GET", "/api/meta/bafyA/claims", "")
|
||||||
|
var cr ClaimsResponse
|
||||||
|
_ = json.Unmarshal(rr.Body.Bytes(), &cr)
|
||||||
|
if rr.Code != http.StatusOK || len(cr.Claims) != 2 {
|
||||||
|
t.Fatalf("claims by cid = %d %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
if cr.RID == "" || len(cr.CIDs) != 1 || cr.CIDs[0] != "bafyA" {
|
||||||
|
t.Fatalf("claims envelope rid/cids = %q %v", cr.RID, cr.CIDs)
|
||||||
|
}
|
||||||
|
rr = call(t, s, "GET", "/api/metadata/"+cr.RID+"/claims?key=title&uid="+a.UID, "")
|
||||||
|
var byRid ClaimsResponse
|
||||||
|
_ = json.Unmarshal(rr.Body.Bytes(), &byRid)
|
||||||
|
if rr.Code != http.StatusOK || len(byRid.Claims) != 1 || byRid.Claims[0].UID != a.UID || byRid.RID != cr.RID {
|
||||||
|
t.Fatalf("claims by rid with key+uid = %d %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
if rr := call(t, s, "GET", "/api/meta/bafyA/claims?key=nope", ""); !strings.Contains(rr.Body.String(), `"claims":[]`) {
|
||||||
|
t.Fatalf("key filter = %s", rr.Body)
|
||||||
|
}
|
||||||
|
if rr := call(t, s, "GET", "/api/meta/bafyUnknown/claims", ""); rr.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("unknown cid claims = %d", rr.Code)
|
||||||
|
}
|
||||||
|
if rr := call(t, s, "GET", "/api/metadata/01NOSUCHRID/claims", ""); rr.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("unknown rid claims = %d", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?as= overlays the viewer's own vote.
|
||||||
|
me := testSigner(t, 9)
|
||||||
|
storeClaims(t, s.storage, signed(t, me, "bafyA", "title", "My title", 7))
|
||||||
|
if rr := call(t, s, "GET", "/api/meta/bafyA?as="+me.UID, ""); !strings.Contains(rr.Body.String(), `"title":"My title"`) {
|
||||||
|
t.Fatalf("?as overlay = %s", rr.Body)
|
||||||
|
}
|
||||||
|
if rr := call(t, s, "GET", "/api/meta/bafyA", ""); !strings.Contains(rr.Body.String(), `"title":"Matrix"`) {
|
||||||
|
t.Fatalf("shared view changed by a single viewer: %s", rr.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeEndpoint(t *testing.T) {
|
||||||
|
s := routedServer(t)
|
||||||
|
a, b, c := testSigner(t, 1), testSigner(t, 2), testSigner(t, 3)
|
||||||
|
bad := signed(t, c, "bafyX", "title", "Forged", 1)
|
||||||
|
bad.UID = a.UID
|
||||||
|
body, _ := json.Marshal(MergeRequest{Claims: []claim.Claim{
|
||||||
|
signed(t, a, "bafyX", "title", "Dune", 2),
|
||||||
|
signed(t, b, "bafyX", "title", "Dune", 3),
|
||||||
|
signed(t, c, "bafyX", "title", "Dune Part One", 1),
|
||||||
|
bad,
|
||||||
|
}})
|
||||||
|
rr := call(t, s, "POST", "/api/merge", string(body))
|
||||||
|
var mr MergeResponse
|
||||||
|
_ = json.Unmarshal(rr.Body.Bytes(), &mr)
|
||||||
|
if rr.Code != http.StatusOK || mr.Fields["title"] != "Dune" || mr.Nsrc["title"] != 2 || mr.Rejected != 1 {
|
||||||
|
t.Fatalf("merge = %d %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), `"rejected":1`) || !strings.Contains(rr.Body.String(), `"nsrc":{"title":2}`) {
|
||||||
|
t.Fatalf("merge wire shape = %s", rr.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `as`: the viewer's own vote in the request wins…
|
||||||
|
me := testSigner(t, 9)
|
||||||
|
body, _ = json.Marshal(MergeRequest{As: me.UID, Claims: []claim.Claim{
|
||||||
|
signed(t, a, "bafyX", "title", "Dune", 2),
|
||||||
|
signed(t, b, "bafyX", "title", "Dune", 3),
|
||||||
|
signed(t, me, "bafyX", "title", "Dune (my cut)", 4),
|
||||||
|
}})
|
||||||
|
_ = json.Unmarshal(call(t, s, "POST", "/api/merge", string(body)).Body.Bytes(), &mr)
|
||||||
|
if mr.Fields["title"] != "Dune (my cut)" || mr.Nsrc["title"] != 1 {
|
||||||
|
t.Fatalf("request overlay = %+v", mr)
|
||||||
|
}
|
||||||
|
// …and so do its votes held by this core for the same cids.
|
||||||
|
storeClaims(t, s.storage, signed(t, me, "bafyX", "year", "2021", 5))
|
||||||
|
body, _ = json.Marshal(MergeRequest{As: me.UID, Claims: []claim.Claim{
|
||||||
|
signed(t, a, "bafyX", "title", "Dune", 2),
|
||||||
|
signed(t, a, "bafyX", "year", "1984", 2),
|
||||||
|
}})
|
||||||
|
_ = json.Unmarshal(call(t, s, "POST", "/api/merge", string(body)).Body.Bytes(), &mr)
|
||||||
|
if mr.Fields["year"] != "2021" || mr.Fields["title"] != "Dune" {
|
||||||
|
t.Fatalf("local overlay = %+v", mr)
|
||||||
|
}
|
||||||
|
// An all-invalid batch is still 200 with everything rejected.
|
||||||
|
bad2 := signed(t, a, "bafyX", "title", "x", 9)
|
||||||
|
bad2.Sig = bad2.Sig[:10]
|
||||||
|
body, _ = json.Marshal(MergeRequest{Claims: []claim.Claim{bad2}})
|
||||||
|
rr = call(t, s, "POST", "/api/merge", string(body))
|
||||||
|
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"rejected":1`) {
|
||||||
|
t.Fatalf("all-invalid merge = %d %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTooManyClaimsIs413(t *testing.T) {
|
||||||
|
s := routedServer(t)
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("[")
|
||||||
|
for i := 0; i <= storage.MaxClaimsPerBatch; i++ {
|
||||||
|
if i > 0 {
|
||||||
|
sb.WriteString(",")
|
||||||
|
}
|
||||||
|
sb.WriteString(`{"cid":"c","uid":"u","key":"k","value":null,"ts":1,"sig":""}`)
|
||||||
|
}
|
||||||
|
sb.WriteString("]")
|
||||||
|
if rr := call(t, s, "POST", "/api/claims", sb.String()); rr.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("oversized batch = %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The per-algorithm CID fields (`cid_sha2-256`, `cid_midhash256`, …) and the
|
|
||||||
// stored `canonical_cid` scalar were removed (METADATA_KEYS.md §14.13). They
|
|
||||||
// must be rejected at the write boundary rather than stored.
|
|
||||||
//
|
|
||||||
// Why a hard 400 and not a silent rewrite: the reverse-index hook
|
|
||||||
// (maybeAddAliasFromFieldLocked) only registers an alias for a field named
|
|
||||||
// `cids/<cid>`. A `cid_*` field was therefore written verbatim and never
|
|
||||||
// indexed — the record silently became unresolvable by that CID. A loud
|
|
||||||
// failure is strictly better than a record that looks correct and cannot be
|
|
||||||
// found.
|
|
||||||
func TestRejectDeprecatedField(t *testing.T) {
|
|
||||||
rejected := []string{
|
|
||||||
"cid_midhash256",
|
|
||||||
"cid_sha2-256",
|
|
||||||
"cid_btih_v2",
|
|
||||||
"cid_crc32",
|
|
||||||
"midhash256",
|
|
||||||
"canonical_cid",
|
|
||||||
}
|
|
||||||
for _, f := range rejected {
|
|
||||||
if !rejectDeprecatedField(f) {
|
|
||||||
t.Errorf("rejectDeprecatedField(%q) = false, want true (deprecated shape)", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
accepted := []string{
|
|
||||||
"cids/bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4",
|
|
||||||
"filePath",
|
|
||||||
"title",
|
|
||||||
"fileinfo/duration",
|
|
||||||
"tmdb/poster",
|
|
||||||
// Near-misses that must NOT be caught: the prefix is `cid_`, with an
|
|
||||||
// underscore. A field merely starting with "cid" is fine.
|
|
||||||
"cids",
|
|
||||||
"cidrank",
|
|
||||||
}
|
|
||||||
for _, f := range accepted {
|
|
||||||
if rejectDeprecatedField(f) {
|
|
||||||
t.Errorf("rejectDeprecatedField(%q) = true, want false (legitimate field)", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateFields_Rejects400WithSlug(t *testing.T) {
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
blocked := validateFields(rr, map[string]string{
|
|
||||||
"title": "Inception",
|
|
||||||
"cid_midhash256": "bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4",
|
|
||||||
})
|
|
||||||
|
|
||||||
if !blocked {
|
|
||||||
t.Fatal("validateFields returned false for a payload carrying cid_midhash256; the write would have been stored unindexed")
|
|
||||||
}
|
|
||||||
if rr.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body map[string]interface{}
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
|
||||||
t.Fatalf("response is not JSON: %v", err)
|
|
||||||
}
|
|
||||||
if got := body["error"]; got != ErrDeprecatedField {
|
|
||||||
t.Errorf("error slug = %v, want %q", got, ErrDeprecatedField)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateFields_AllowsCidsKeyset(t *testing.T) {
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
blocked := validateFields(rr, map[string]string{
|
|
||||||
"filePath": "/watch/Inception.mkv",
|
|
||||||
"cids/bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4": "true",
|
|
||||||
"cids/baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua": "true",
|
|
||||||
})
|
|
||||||
if blocked {
|
|
||||||
t.Fatalf("validateFields rejected a legitimate cids/ key-set write: %s", rr.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,24 +9,16 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/alicebob/miniredis/v2"
|
|
||||||
"github.com/metazla/meta-core/internal/config"
|
"github.com/metazla/meta-core/internal/config"
|
||||||
"github.com/metazla/meta-core/internal/storage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// newRegisterTestServer wires handleRegisterFile to an in-memory Redis and a
|
// newRegisterTestServer wires handleRegisterFile to a temp store and a temp
|
||||||
// temp FILES_PATH. Built directly, not through NewServer, so no watcher runs —
|
// FILES_PATH. Built directly, not through NewServer, so no watcher runs —
|
||||||
// which is exactly the configuration this route exists for.
|
// which is exactly the configuration this route exists for.
|
||||||
func newRegisterTestServer(t *testing.T) (*Server, string, *miniredis.Miniredis) {
|
func newRegisterTestServer(t *testing.T) (*Server, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mr := miniredis.RunT(t)
|
|
||||||
c := storage.NewClient("")
|
|
||||||
if err := c.Connect("redis://" + mr.Addr()); err != nil {
|
|
||||||
t.Fatalf("connect to miniredis: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = c.Close() })
|
|
||||||
files := t.TempDir()
|
files := t.TempDir()
|
||||||
return &Server{storage: c, config: &config.Config{FilesPath: files}}, files, mr
|
return &Server{storage: newTestStorage(t), config: &config.Config{FilesPath: files}}, files
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeFixture(t *testing.T, files, rel, body string) string {
|
func writeFixture(t *testing.T, files, rel, body string) string {
|
||||||
@@ -52,20 +44,20 @@ func postRegister(t *testing.T, s *Server, path string) (int, registerFileRespon
|
|||||||
return rr.Code, resp
|
return rr.Code, resp
|
||||||
}
|
}
|
||||||
|
|
||||||
func streamLen(t *testing.T, mr *miniredis.Miniredis) int {
|
func streamLen(t *testing.T, s *Server) int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
entries, err := mr.Stream("file:events")
|
n, err := s.storage.GetStreamLength("file:events")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
t.Fatalf("stream length: %v", err)
|
||||||
}
|
}
|
||||||
return len(entries)
|
return int(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The whole point of the route: with the watcher off, a file written under
|
// The whole point of the route: with the watcher off, a file written under
|
||||||
// plugin/ becomes a record resolvable by its midhash, and meta-share hears
|
// plugin/ becomes a record resolvable by its midhash, and meta-share hears
|
||||||
// about it on file:events.
|
// about it on file:events.
|
||||||
func TestRegisterFile_MintsAliasAndPublishesEvent(t *testing.T) {
|
func TestRegisterFile_MintsAliasAndPublishesEvent(t *testing.T) {
|
||||||
s, files, mr := newRegisterTestServer(t)
|
s, files := newRegisterTestServer(t)
|
||||||
rel := "plugin/subtitles/ab12cd34.eng.srt"
|
rel := "plugin/subtitles/ab12cd34.eng.srt"
|
||||||
full := writeFixture(t, files, rel, "1\n00:00:01,000 --> 00:00:02,000\nHello\n")
|
full := writeFixture(t, files, rel, "1\n00:00:01,000 --> 00:00:02,000\nHello\n")
|
||||||
|
|
||||||
@@ -85,20 +77,19 @@ func TestRegisterFile_MintsAliasAndPublishesEvent(t *testing.T) {
|
|||||||
t.Fatalf("filePath = %q, want %q", fp, full)
|
t.Fatalf("filePath = %q, want %q", fp, full)
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := mr.Stream("file:events")
|
entries, err := s.storage.XRevRange("file:events", "+", "-", 0)
|
||||||
if err != nil || len(entries) != 1 {
|
if err != nil || len(entries) != 1 {
|
||||||
t.Fatalf("want exactly one file:events entry, got %d err=%v", len(entries), err)
|
t.Fatalf("want exactly one file:events entry, got %d err=%v", len(entries), err)
|
||||||
}
|
}
|
||||||
vals := strings.Join(entries[0].Values, "|")
|
if vals := entries[0].Values; vals["midhash256"] != resp.CID || vals["path"] != rel {
|
||||||
if !strings.Contains(vals, "midhash256|"+resp.CID) || !strings.Contains(vals, "path|"+rel) {
|
t.Fatalf("event must carry the midhash and the relative path, got %v", vals)
|
||||||
t.Fatalf("event must carry the midhash and the relative path, got %q", vals)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-uploading the same subtitle is a no-op: same root, no second event (which
|
// Re-uploading the same subtitle is a no-op: same root, no second event (which
|
||||||
// would make meta-share re-chunk and re-provide the file).
|
// would make meta-share re-chunk and re-provide the file).
|
||||||
func TestRegisterFile_IsIdempotent(t *testing.T) {
|
func TestRegisterFile_IsIdempotent(t *testing.T) {
|
||||||
s, files, mr := newRegisterTestServer(t)
|
s, files := newRegisterTestServer(t)
|
||||||
rel := "plugin/subtitles/same.eng.srt"
|
rel := "plugin/subtitles/same.eng.srt"
|
||||||
writeFixture(t, files, rel, "same bytes")
|
writeFixture(t, files, rel, "same bytes")
|
||||||
|
|
||||||
@@ -110,7 +101,7 @@ func TestRegisterFile_IsIdempotent(t *testing.T) {
|
|||||||
if second.Root != first.Root || second.CID != first.CID {
|
if second.Root != first.Root || second.CID != first.CID {
|
||||||
t.Fatalf("second register must return the same record: %+v vs %+v", second, first)
|
t.Fatalf("second register must return the same record: %+v vs %+v", second, first)
|
||||||
}
|
}
|
||||||
if n := streamLen(t, mr); n != 1 {
|
if n := streamLen(t, s); n != 1 {
|
||||||
t.Fatalf("an idempotent re-register must not publish again, stream has %d", n)
|
t.Fatalf("an idempotent re-register must not publish again, stream has %d", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +109,7 @@ func TestRegisterFile_IsIdempotent(t *testing.T) {
|
|||||||
// Only plugin output may be registered here. /files/watch is the user's
|
// Only plugin output may be registered here. /files/watch is the user's
|
||||||
// library and belongs to the watcher; traversal must not escape either.
|
// library and belongs to the watcher; traversal must not escape either.
|
||||||
func TestRegisterFile_RejectsPathsOutsidePlugin(t *testing.T) {
|
func TestRegisterFile_RejectsPathsOutsidePlugin(t *testing.T) {
|
||||||
s, files, _ := newRegisterTestServer(t)
|
s, files := newRegisterTestServer(t)
|
||||||
writeFixture(t, files, "watch/movie.mkv", "x")
|
writeFixture(t, files, "watch/movie.mkv", "x")
|
||||||
for _, p := range []string{
|
for _, p := range []string{
|
||||||
"", "plugin", "plugin/", "watch/movie.mkv", "/watch/movie.mkv",
|
"", "plugin", "plugin/", "watch/movie.mkv", "/watch/movie.mkv",
|
||||||
@@ -131,7 +122,7 @@ func TestRegisterFile_RejectsPathsOutsidePlugin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterFile_MissingFileAndDirectory(t *testing.T) {
|
func TestRegisterFile_MissingFileAndDirectory(t *testing.T) {
|
||||||
s, files, _ := newRegisterTestServer(t)
|
s, files := newRegisterTestServer(t)
|
||||||
if code, _ := postRegister(t, s, "plugin/subtitles/nope.srt"); code != http.StatusNotFound {
|
if code, _ := postRegister(t, s, "plugin/subtitles/nope.srt"); code != http.StatusNotFound {
|
||||||
t.Errorf("missing file: want 404, got %d", code)
|
t.Errorf("missing file: want 404, got %d", code)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,12 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/alicebob/miniredis/v2"
|
|
||||||
"github.com/metazla/meta-core/internal/storage"
|
"github.com/metazla/meta-core/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newFileTuplesTestServer(t *testing.T) (*Server, *storage.Client) {
|
func newFileTuplesTestServer(t *testing.T) (*Server, *storage.Client) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mr := miniredis.RunT(t)
|
c := newTestStorage(t)
|
||||||
c := storage.NewClient("")
|
|
||||||
if err := c.Connect("redis://" + mr.Addr()); err != nil {
|
|
||||||
t.Fatalf("connect to miniredis: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = c.Close() })
|
|
||||||
return &Server{storage: c}, c
|
return &Server{storage: c}, c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,25 +28,25 @@ func getFileTuples(t *testing.T, s *Server, query string) (int, FileTuplesRespon
|
|||||||
|
|
||||||
func mustMint(t *testing.T, c *storage.Client, path string, size, mtime int64) string {
|
func mustMint(t *testing.T, c *storage.Client, path string, size, mtime int64) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
id, err := c.Mint(path, size, mtime)
|
id, _, err := c.RegisterFileTuple(path, size, mtime, "bafkmid"+path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mint %s: %v", path, err)
|
t.Fatalf("register %s: %v", path, err)
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only records that are actually files count: a gateway record and a record
|
// Only local files count: a gateway record — even one whose claims carry a
|
||||||
// that merely carries a path + size (no mtimeNano) are both left out.
|
// filePath and size — is left out.
|
||||||
func TestFileTuples_OnlyFileBackedRoots(t *testing.T) {
|
func TestFileTuples_OnlyFileBackedRoots(t *testing.T) {
|
||||||
s, c := newFileTuplesTestServer(t)
|
s, c := newFileTuplesTestServer(t)
|
||||||
a := mustMint(t, c, "/files/watch/a.mkv", 1000, 1)
|
a := mustMint(t, c, "/files/watch/a.mkv", 1000, 1)
|
||||||
b := mustMint(t, c, "/files/watch/b.mkv", 2500, 2)
|
b := mustMint(t, c, "/files/watch/b.mkv", 2500, 2)
|
||||||
if err := c.SetMetadataFlat("bagcsgatewayrecord", map[string]string{"title": "Some Release"}); err != nil {
|
gw := testSigner(t, 1)
|
||||||
t.Fatal(err)
|
storeClaims(t, c,
|
||||||
}
|
signed(t, gw, "bagcsgatewayrecord", "title", "Some Release", 1),
|
||||||
if err := c.SetMetadataFlat("01partialroot", map[string]string{"filePath": "/files/cache/x.mkv", "sizeByte": "999"}); err != nil {
|
signed(t, gw, "bafkpartial", "filePath", "/files/cache/x.mkv", 1),
|
||||||
t.Fatal(err)
|
signed(t, gw, "bafkpartial", "sizeByte", "999", 1),
|
||||||
}
|
)
|
||||||
|
|
||||||
code, resp := getFileTuples(t, s, "")
|
code, resp := getFileTuples(t, s, "")
|
||||||
if code != http.StatusOK {
|
if code != http.StatusOK {
|
||||||
@@ -105,7 +99,7 @@ func TestFileTuples_EmptyLibraryKeepsFilesKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFileTuples_StorageNotConnected(t *testing.T) {
|
func TestFileTuples_StorageNotConnected(t *testing.T) {
|
||||||
s := &Server{storage: storage.NewClient("")}
|
s := &Server{storage: storage.NewClient()}
|
||||||
|
|
||||||
code, _ := getFileTuples(t, s, "")
|
code, _ := getFileTuples(t, s, "")
|
||||||
if code != http.StatusServiceUnavailable {
|
if code != http.StatusServiceUnavailable {
|
||||||
|
|||||||
+1
-317
@@ -4,7 +4,6 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base32"
|
"encoding/base32"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -89,42 +88,6 @@ const (
|
|||||||
ErrDeprecatedField = "deprecated_field"
|
ErrDeprecatedField = "deprecated_field"
|
||||||
)
|
)
|
||||||
|
|
||||||
// rejectDeprecatedField reports whether a field name is one of the removed
|
|
||||||
// per-algorithm CID shapes (`cid_sha2-256`, `cid_midhash256`, `midhash256`, …).
|
|
||||||
//
|
|
||||||
// This is a tripwire, not a compatibility shim. Such a field used to be
|
|
||||||
// accepted and written verbatim — but the reverse-index hook
|
|
||||||
// (`maybeAddAliasFromFieldLocked`) only registers aliases for fields named
|
|
||||||
// `cids/<cid>`, so a `cid_*` field was stored and then NEVER INDEXED: the
|
|
||||||
// record silently became unresolvable by that CID. Failing the write loudly is
|
|
||||||
// strictly better than a record that looks fine and cannot be found.
|
|
||||||
//
|
|
||||||
// Writers must emit the bare-CID key-set instead: `cids/<bareCid>` = "true".
|
|
||||||
// See METADATA_KEYS.md §2/§14.13.
|
|
||||||
func rejectDeprecatedField(field string) bool {
|
|
||||||
return strings.HasPrefix(field, "cid_") ||
|
|
||||||
field == "midhash256" ||
|
|
||||||
field == "canonical_cid"
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateFields rejects a write carrying any deprecated CID field. Returns
|
|
||||||
// true if the request was rejected (the handler must return immediately).
|
|
||||||
func validateFields(w http.ResponseWriter, metadata map[string]string) bool {
|
|
||||||
for field := range metadata {
|
|
||||||
if rejectDeprecatedField(field) {
|
|
||||||
writeErrorSlug(w, http.StatusBadRequest, ErrDeprecatedField,
|
|
||||||
fmt.Sprintf("field %q is a removed per-algorithm CID shape; "+
|
|
||||||
"write the bare-CID key-set instead: \"cids/<bareCid>\": \"true\" "+
|
|
||||||
"(a cid_* field would be stored but never indexed, leaving the "+
|
|
||||||
"record unresolvable by that CID). See METADATA_KEYS.md §2/§14.13.",
|
|
||||||
field),
|
|
||||||
false)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaultSlugFor maps an HTTP status code to the most likely error slug.
|
// defaultSlugFor maps an HTTP status code to the most likely error slug.
|
||||||
// Handlers that want to override (e.g. /api/meta/{cid} → "unknown_cid"
|
// Handlers that want to override (e.g. /api/meta/{cid} → "unknown_cid"
|
||||||
// instead of "unknown_root" on 404) call writeErrorSlug directly.
|
// instead of "unknown_root" on 404) call writeErrorSlug directly.
|
||||||
@@ -250,7 +213,7 @@ func (s *Server) handleGetMeta(w http.ResponseWriter, r *http.Request) {
|
|||||||
// the watcher minted the root, regardless of which CID the caller knows.
|
// the watcher minted the root, regardless of which CID the caller knows.
|
||||||
hashID = s.storage.ResolveRoot(hashID)
|
hashID = s.storage.ResolveRoot(hashID)
|
||||||
|
|
||||||
metadata, err := s.storage.GetMetadataFlat(hashID)
|
metadata, err := s.storage.GetMetadataFlatAs(hashID, r.URL.Query().Get("as"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -269,76 +232,6 @@ func (s *Server) handleGetMeta(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, response)
|
writeJSON(w, http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePutMeta handles PUT /meta/{hash}
|
|
||||||
func (s *Server) handlePutMeta(w http.ResponseWriter, r *http.Request) {
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
hashID := vars["hash"]
|
|
||||||
|
|
||||||
if hashID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "hash is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// CID → UUID resolution so writes land on the canonical root, not on
|
|
||||||
// a parallel midhash-rooted entry the watcher's UUID never sees.
|
|
||||||
hashID = s.storage.ResolveRoot(hashID)
|
|
||||||
|
|
||||||
var metadata map[string]string
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if validateFields(w, metadata) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.storage.SetMetadataFlat(hashID, metadata); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"hashId": hashID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDeleteMeta handles DELETE /meta/{hash}
|
|
||||||
func (s *Server) handleDeleteMeta(w http.ResponseWriter, r *http.Request) {
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
hashID := vars["hash"]
|
|
||||||
|
|
||||||
if hashID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "hash is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hashID = s.storage.ResolveRoot(hashID)
|
|
||||||
|
|
||||||
deleted, err := s.storage.DeleteMetadata(hashID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"hashId": hashID,
|
|
||||||
"deleted": deleted,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleListMeta handles GET /meta
|
// handleListMeta handles GET /meta
|
||||||
func (s *Server) handleListMeta(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListMeta(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.storage.IsConnected() {
|
||||||
@@ -623,50 +516,6 @@ func writeErrorSlug(w http.ResponseWriter, status int, slug, message string, ret
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePatchMeta handles PATCH /meta/{hash}
|
|
||||||
// Merges the provided metadata into existing (does not delete missing keys)
|
|
||||||
func (s *Server) handlePatchMeta(w http.ResponseWriter, r *http.Request) {
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
hashID := vars["hash"]
|
|
||||||
|
|
||||||
if hashID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "hash is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH is the hot write path for meta-sort. Resolve so plugin output
|
|
||||||
// merges into the watcher's UUID root rather than minting a parallel
|
|
||||||
// midhash-rooted entry.
|
|
||||||
hashID = s.storage.ResolveRoot(hashID)
|
|
||||||
|
|
||||||
var metadata map[string]string
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if validateFields(w, metadata) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.storage.MergeMetadataFlat(hashID, metadata)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"hashId": hashID,
|
|
||||||
"updated": updated,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetProperty handles GET /meta/{hash}/{key}
|
// handleGetProperty handles GET /meta/{hash}/{key}
|
||||||
// Gets a single property value
|
// Gets a single property value
|
||||||
func (s *Server) handleGetProperty(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetProperty(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -713,171 +562,6 @@ type PropertyBody struct {
|
|||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePutProperty handles PUT /meta/{hash}/{key}
|
|
||||||
// Sets a single property value
|
|
||||||
// Accepts JSON body with {"value": "..."} or plain text
|
|
||||||
func (s *Server) handlePutProperty(w http.ResponseWriter, r *http.Request) {
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
hashID := vars["hash"]
|
|
||||||
key := vars["key"]
|
|
||||||
|
|
||||||
if hashID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "hash is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if key == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "key is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if validateFields(w, map[string]string{key: ""}) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hashID = s.storage.ResolveRoot(hashID)
|
|
||||||
|
|
||||||
// Read body
|
|
||||||
buf := make([]byte, 1024*1024) // 1MB max
|
|
||||||
n, err := r.Body.Read(buf)
|
|
||||||
if err != nil && err.Error() != "EOF" {
|
|
||||||
writeError(w, http.StatusBadRequest, "failed to read body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body := buf[:n]
|
|
||||||
|
|
||||||
// Try to parse as JSON {"value": "..."} first
|
|
||||||
var propBody PropertyBody
|
|
||||||
var value string
|
|
||||||
if err := json.Unmarshal(body, &propBody); err == nil && propBody.Value != "" {
|
|
||||||
// JSON body with value field
|
|
||||||
value = propBody.Value
|
|
||||||
} else {
|
|
||||||
// Plain text body
|
|
||||||
value = string(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.storage.SetProperty(hashID, key, value); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"hashId": hashID,
|
|
||||||
"property": key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDeleteProperty handles DELETE /meta/{hash}/{key}
|
|
||||||
// Deletes a single property
|
|
||||||
func (s *Server) handleDeleteProperty(w http.ResponseWriter, r *http.Request) {
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
hashID := vars["hash"]
|
|
||||||
key := vars["key"]
|
|
||||||
|
|
||||||
if hashID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "hash is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if key == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "key is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hashID = s.storage.ResolveRoot(hashID)
|
|
||||||
|
|
||||||
if err := s.storage.DeleteProperty(hashID, key); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"hashId": hashID,
|
|
||||||
"property": key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAddToSet handles POST /meta/{hash}/_add/{key}
|
|
||||||
// Adds a value to a set-type field
|
|
||||||
// Accepts JSON body with {"value": "..."} or plain text
|
|
||||||
func (s *Server) handleAddToSet(w http.ResponseWriter, r *http.Request) {
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
hashID := vars["hash"]
|
|
||||||
key := vars["key"]
|
|
||||||
|
|
||||||
if hashID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "hash is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if key == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "key is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if validateFields(w, map[string]string{key: ""}) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hashID = s.storage.ResolveRoot(hashID)
|
|
||||||
|
|
||||||
// Read body
|
|
||||||
buf := make([]byte, 1024*1024) // 1MB max
|
|
||||||
n, err := r.Body.Read(buf)
|
|
||||||
if err != nil && err.Error() != "EOF" {
|
|
||||||
writeError(w, http.StatusBadRequest, "failed to read body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body := buf[:n]
|
|
||||||
|
|
||||||
// Try to parse as JSON {"value": "..."} first
|
|
||||||
var propBody PropertyBody
|
|
||||||
var value string
|
|
||||||
if err := json.Unmarshal(body, &propBody); err == nil && propBody.Value != "" {
|
|
||||||
// JSON body with value field
|
|
||||||
value = propBody.Value
|
|
||||||
} else {
|
|
||||||
// Plain text body
|
|
||||||
value = string(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
if value == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "value is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
added, err := s.storage.AddToSet(hashID, key, value)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"hashId": hashID,
|
|
||||||
"property": key,
|
|
||||||
"added": added,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CIDRequest is the request body for POST /file/cid
|
// CIDRequest is the request body for POST /file/cid
|
||||||
type CIDRequest struct {
|
type CIDRequest struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/claim"
|
||||||
"github.com/metazla/meta-core/internal/identity"
|
"github.com/metazla/meta-core/internal/identity"
|
||||||
"github.com/metazla/meta-core/internal/storage"
|
"github.com/metazla/meta-core/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -120,6 +123,9 @@ type identityDeleteResponse struct {
|
|||||||
|
|
||||||
type identitySignRequest struct {
|
type identitySignRequest struct {
|
||||||
BytesB64 string `json:"bytesB64"`
|
BytesB64 string `json:"bytesB64"`
|
||||||
|
// Format is "der" or "compact" (64-byte r||s, low-S). Default: compact for
|
||||||
|
// MMUR2 claim payloads ("MMUR2\n" prefix), DER otherwise (MMUR1 back-compat).
|
||||||
|
Format string `json:"format,omitempty"`
|
||||||
// UID selects which account signs. Optional — when omitted and exactly one
|
// UID selects which account signs. Optional — when omitted and exactly one
|
||||||
// account exists, that account is used (single-identity back-compat).
|
// account exists, that account is used (single-identity back-compat).
|
||||||
UID string `json:"uid,omitempty"`
|
UID string `json:"uid,omitempty"`
|
||||||
@@ -143,6 +149,8 @@ type identitySignBatchRequest struct {
|
|||||||
// UID selects which account signs — same optional single-identity
|
// UID selects which account signs — same optional single-identity
|
||||||
// back-compat as identitySignRequest.
|
// back-compat as identitySignRequest.
|
||||||
UID string `json:"uid,omitempty"`
|
UID string `json:"uid,omitempty"`
|
||||||
|
// Format: see identitySignRequest.Format; applies to every payload.
|
||||||
|
Format string `json:"format,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type identitySignBatchResponse struct {
|
type identitySignBatchResponse struct {
|
||||||
@@ -445,10 +453,10 @@ func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
"storage is not connected; refusing to delete the key while its data cannot be purged", true)
|
"storage is not connected; refusing to delete the key while its data cannot be purged", true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
purged, err := s.storage.UDLPurgeUser(id.UID)
|
purged, err := s.retractAllClaims(r, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorSlug(w, http.StatusInternalServerError, "purge_failed",
|
writeErrorSlug(w, http.StatusInternalServerError, "purge_failed",
|
||||||
"could not purge this account's data; the account was NOT deleted: "+err.Error(), true)
|
"could not retract this account's claims; the account was NOT deleted: "+err.Error(), true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,7 +464,7 @@ func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErrorSlug(w, http.StatusInternalServerError, "delete_failed", err.Error(), false)
|
writeErrorSlug(w, http.StatusInternalServerError, "delete_failed", err.Error(), false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[API] Deleted account %s and purged %d User Data Layer records across %d cids",
|
log.Printf("[API] Deleted account %s after retracting %d claims across %d cids",
|
||||||
id.UID, purged.Records, purged.Cids)
|
id.UID, purged.Records, purged.Cids)
|
||||||
writeJSON(w, http.StatusOK, identityDeleteResponse{UID: id.UID, Purged: purged})
|
writeJSON(w, http.StatusOK, identityDeleteResponse{UID: id.UID, Purged: purged})
|
||||||
}
|
}
|
||||||
@@ -485,9 +493,9 @@ func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErrorSlug(w, http.StatusForbidden, "reserved_payload", reservedPayloadMessage, false)
|
writeErrorSlug(w, http.StatusForbidden, "reserved_payload", reservedPayloadMessage, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sig, err := identity.Sign(id, raw)
|
sig, err := signPayload(id, raw, body.Format)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, identitySignResponse{
|
writeJSON(w, http.StatusOK, identitySignResponse{
|
||||||
@@ -537,9 +545,9 @@ func (s *Server) handleIdentitySignBatch(w http.ResponseWriter, r *http.Request)
|
|||||||
"payloadsB64["+strconv.Itoa(i)+"]: "+reservedPayloadMessage, false)
|
"payloadsB64["+strconv.Itoa(i)+"]: "+reservedPayloadMessage, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sig, err := identity.Sign(id, raw)
|
sig, err := signPayload(id, raw, body.Format)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusBadRequest, "payloadsB64["+strconv.Itoa(i)+"]: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sigs = append(sigs, base64.StdEncoding.EncodeToString(sig))
|
sigs = append(sigs, base64.StdEncoding.EncodeToString(sig))
|
||||||
@@ -562,3 +570,61 @@ func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
AEADKeyB64: base64.StdEncoding.EncodeToString(key),
|
AEADKeyB64: base64.StdEncoding.EncodeToString(key),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signPayload signs raw with the account key in the requested encoding.
|
||||||
|
func signPayload(id *identity.Identity, raw []byte, format string) ([]byte, error) {
|
||||||
|
switch format {
|
||||||
|
case "compact":
|
||||||
|
return identity.SignCompact(id, raw)
|
||||||
|
case "der":
|
||||||
|
return identity.Sign(id, raw)
|
||||||
|
case "":
|
||||||
|
if strings.HasPrefix(string(raw), claim.Domain) {
|
||||||
|
return identity.SignCompact(id, raw)
|
||||||
|
}
|
||||||
|
return identity.Sign(id, raw)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("format must be \"compact\" or \"der\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
// retractAllClaims signs a retraction for every live claim the account holds
|
||||||
|
// and stores them (G11): the tombstones stay, so the votes cannot be replayed.
|
||||||
|
func (s *Server) retractAllClaims(r *http.Request, id *identity.Identity) (storage.UDLUserStats, error) {
|
||||||
|
refs, err := s.storage.LiveClaimsBySigner(id.UID)
|
||||||
|
if err != nil {
|
||||||
|
return storage.UDLUserStats{}, err
|
||||||
|
}
|
||||||
|
cids, keys := map[string]bool{}, map[string]bool{}
|
||||||
|
now := uint64(time.Now().UnixMilli())
|
||||||
|
batch := make([]claim.Claim, 0, len(refs))
|
||||||
|
for _, ref := range refs {
|
||||||
|
cur, ok, err := s.storage.UDLGetClaim(id.UID, ref[0], ref[1])
|
||||||
|
if err != nil {
|
||||||
|
return storage.UDLUserStats{}, err
|
||||||
|
}
|
||||||
|
ts := now
|
||||||
|
if ok && cur.TS >= ts {
|
||||||
|
ts = cur.TS + 1
|
||||||
|
}
|
||||||
|
tomb := claim.Claim{CID: ref[0], UID: id.UID, Key: ref[1], Value: claim.Null(), TS: ts}
|
||||||
|
if err := tomb.Sign(id); err != nil {
|
||||||
|
return storage.UDLUserStats{}, err
|
||||||
|
}
|
||||||
|
batch = append(batch, tomb)
|
||||||
|
cids[ref[0]], keys[ref[1]] = true, true
|
||||||
|
}
|
||||||
|
for start := 0; start < len(batch); start += storage.MaxClaimsPerBatch {
|
||||||
|
end := min(start+storage.MaxClaimsPerBatch, len(batch))
|
||||||
|
results, err := s.storage.WriteClaims(r.Context(), batch[start:end])
|
||||||
|
if err != nil {
|
||||||
|
return storage.UDLUserStats{}, err
|
||||||
|
}
|
||||||
|
for i, res := range results {
|
||||||
|
if res.Status != storage.StatusAccepted && res.Status != storage.StatusUnchanged {
|
||||||
|
return storage.UDLUserStats{}, fmt.Errorf("retraction of %s/%s refused: %s %s",
|
||||||
|
batch[start+i].CID, batch[start+i].Key, res.Status, res.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storage.UDLUserStats{Records: len(batch), Cids: len(cids), Keys: len(keys)}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
// End-to-end exercise of the account gate against a live router + miniredis:
|
// End-to-end exercise of the account gate against a live router + a temp store:
|
||||||
// generate -> write UDL rows -> stats -> challenge -> sign -> delete -> verify
|
// generate -> write UDL rows -> stats -> challenge -> sign -> delete -> verify
|
||||||
// both the key and every row are gone. Deliberately drives the real HTTP
|
// both the key and every row are gone. Deliberately drives the real HTTP
|
||||||
// handlers rather than the packages beneath them, because the bugs that matter
|
// handlers rather than the packages beneath them, because the bugs that matter
|
||||||
@@ -16,21 +16,14 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/alicebob/miniredis/v2"
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/metazla/meta-core/internal/config"
|
"github.com/metazla/meta-core/internal/config"
|
||||||
"github.com/metazla/meta-core/internal/identity"
|
"github.com/metazla/meta-core/internal/identity"
|
||||||
"github.com/metazla/meta-core/internal/storage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestServer(t *testing.T) *Server {
|
func newTestServer(t *testing.T) *Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mr := miniredis.RunT(t)
|
stor := newTestStorage(t)
|
||||||
stor := storage.NewClient("")
|
|
||||||
if err := stor.Connect("redis://" + mr.Addr()); err != nil {
|
|
||||||
t.Fatalf("connect: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = stor.Close() })
|
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
config: &config.Config{MetaCorePath: t.TempDir()},
|
config: &config.Config{MetaCorePath: t.TempDir()},
|
||||||
@@ -103,12 +96,16 @@ func TestE2E_DeleteRequiresProofAndPurgesEverything(t *testing.T) {
|
|||||||
uid := gen["uid"].(string)
|
uid := gen["uid"].(string)
|
||||||
priv := gen["privateKeyHex"].(string)
|
priv := gen["privateKeyHex"].(string)
|
||||||
|
|
||||||
// Some data to lose.
|
// Some data to lose: claims signed by the account.
|
||||||
for _, cell := range [][2]string{{"cidA", "like"}, {"cidA", "rating"}, {"cidB", "seek"}} {
|
acct, err := identity.Import(priv)
|
||||||
if ok, err := s.storage.UDLUpsertIfNewer(uid, cell[0], cell[1], 1, 100, "rec", "", false, false); err != nil || !ok {
|
if err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatal(err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
storeClaims(t, s.storage,
|
||||||
|
signed(t, acct, "cidA", "like", "true", 100),
|
||||||
|
signed(t, acct, "cidA", "userRating", "5", 100),
|
||||||
|
signed(t, acct, "cidB", "seek", []byte("enc"), 100),
|
||||||
|
)
|
||||||
|
|
||||||
// The count the UI shows in the confirmation.
|
// The count the UI shows in the confirmation.
|
||||||
rec, stats := do(t, s, "GET", "/api/udl/users/stats", nil)
|
rec, stats := do(t, s, "GET", "/api/udl/users/stats", nil)
|
||||||
@@ -182,7 +179,7 @@ func TestE2E_DeleteRequiresProofAndPurgesEverything(t *testing.T) {
|
|||||||
if identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
|
if identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
|
||||||
t.Fatal("the account file survived the delete")
|
t.Fatal("the account file survived the delete")
|
||||||
}
|
}
|
||||||
// Data gone — the orphaning this whole change exists to prevent.
|
// Every claim retracted (tombstones kept) — nothing left to orphan.
|
||||||
after, err := s.storage.UDLAllUserStats()
|
after, err := s.storage.UDLAllUserStats()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("stats after: %v", err)
|
t.Fatalf("stats after: %v", err)
|
||||||
|
|||||||
+10
-41
@@ -206,10 +206,10 @@ type KVSearchResponse struct {
|
|||||||
|
|
||||||
// KVFindResponse is the response for GET /api/kv/find
|
// KVFindResponse is the response for GET /api/kv/find
|
||||||
type KVFindResponse struct {
|
type KVFindResponse struct {
|
||||||
Contains string `json:"contains"`
|
Contains string `json:"contains"`
|
||||||
Fields []string `json:"fields"`
|
Fields []string `json:"fields"`
|
||||||
Matches []storage.FindMatch `json:"matches"`
|
Matches []storage.FindMatch `json:"matches"`
|
||||||
Truncated bool `json:"truncated"`
|
Truncated bool `json:"truncated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// KVValueResponse is the response for GET /api/kv/value
|
// KVValueResponse is the response for GET /api/kv/value
|
||||||
@@ -383,8 +383,9 @@ func (s *Server) handleKVValueGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleKVValuePut handles PUT /api/kv/value with body {key, value}.
|
// handleKVValuePut handles PUT /api/kv/value with body {key, value}: service
|
||||||
// Maintains the file index when the key matches file:<hashId>/...
|
// scratch KV only (btmagnet:*, searchcov:*), kept in the cache. Merged records
|
||||||
|
// and bookkeeping are refused — metadata is written as signed claims.
|
||||||
func (s *Server) handleKVValuePut(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleKVValuePut(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.storage.IsConnected() {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
@@ -401,19 +402,11 @@ func (s *Server) handleKVValuePut(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.storage.SetRawValue(req.Key, req.Value); err != nil {
|
if err := s.storage.SetServiceValue(req.Key, req.Value); err != nil {
|
||||||
writeError(w, http.StatusConflict, err.Error())
|
writeError(w, http.StatusConflict, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if hashID := extractFileHashID(req.Key); hashID != "" {
|
|
||||||
if err := s.storage.IndexAdd(hashID); err != nil {
|
|
||||||
// Non-fatal: the value was written; index entry is best-effort.
|
|
||||||
// Surface in the log path of writeJSON via response header.
|
|
||||||
w.Header().Set("X-KV-Index-Warning", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, KVValueResponse{
|
writeJSON(w, http.StatusOK, KVValueResponse{
|
||||||
Key: req.Key,
|
Key: req.Key,
|
||||||
Type: "string",
|
Type: "string",
|
||||||
@@ -422,9 +415,7 @@ func (s *Server) handleKVValuePut(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleKVValueDelete handles DELETE /api/kv/value?key=...
|
// handleKVValueDelete handles DELETE /api/kv/value?key=... (service keys only).
|
||||||
// When the key matches file:<hashId>/..., removes the file index entry if no
|
|
||||||
// sibling keys remain after the delete.
|
|
||||||
func (s *Server) handleKVValueDelete(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleKVValueDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.storage.IsConnected() {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
@@ -437,36 +428,14 @@ func (s *Server) handleKVValueDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deleted, err := s.storage.DeleteRawKey(key)
|
deleted, err := s.storage.DeleteServiceValue(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusConflict, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if deleted {
|
|
||||||
if hashID := extractFileHashID(key); hashID != "" {
|
|
||||||
if _, err := s.storage.IndexRemoveIfEmpty(hashID); err != nil {
|
|
||||||
w.Header().Set("X-KV-Index-Warning", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, KVValueDeleteResponse{
|
writeJSON(w, http.StatusOK, KVValueDeleteResponse{
|
||||||
Key: key,
|
Key: key,
|
||||||
Deleted: deleted,
|
Deleted: deleted,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractFileHashID returns the hashID for a key shaped like file:<hashId>/...
|
|
||||||
// Returns "" when the key does not follow that convention.
|
|
||||||
func extractFileHashID(key string) string {
|
|
||||||
if !strings.HasPrefix(key, "file:") {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
rest := strings.TrimPrefix(key, "file:")
|
|
||||||
slash := strings.Index(rest, "/")
|
|
||||||
if slash <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return rest[:slash]
|
|
||||||
}
|
|
||||||
|
|||||||
+13
-206
@@ -38,11 +38,14 @@ type MetadataSearchRequest struct {
|
|||||||
Property string `json:"property,omitempty"`
|
Property string `json:"property,omitempty"`
|
||||||
PropertyValue string `json:"propertyValue,omitempty"`
|
PropertyValue string `json:"propertyValue,omitempty"`
|
||||||
Limit int `json:"limit,omitempty"`
|
Limit int `json:"limit,omitempty"`
|
||||||
|
// As is a viewer uid: the search sees that signer's own claims overlaid
|
||||||
|
// (and their records as candidates). Also accepted as ?as=.
|
||||||
|
As string `json:"as,omitempty"`
|
||||||
|
|
||||||
// Filters is the structured form: AND across entries, OR within an
|
// Filters is the structured form: AND across entries, OR within an
|
||||||
// entry's Values. Evaluated in memory against the search index alongside
|
// entry's Values. Narrowed in SQL through the field index alongside the
|
||||||
// the free-text Query, so an id-anchored lookup costs a map probe per
|
// free-text Query, then verified per record (see serveIndexedSearch), so an
|
||||||
// record rather than a Redis round-trip per record.
|
// id-anchored lookup costs an index probe rather than a read per record.
|
||||||
//
|
//
|
||||||
// Property/PropertyValue is the single-filter legacy shape and folds into
|
// Property/PropertyValue is the single-filter legacy shape and folds into
|
||||||
// this list (see normalizedFilters) with Exact=false, preserving its
|
// this list (see normalizedFilters) with Exact=false, preserving its
|
||||||
@@ -222,10 +225,10 @@ func (s *Server) handleGetMetadataByHashId(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same resolution as the PUT below: callers hold a CID, not the root.
|
// Callers may hold a CID, not the root.
|
||||||
hashID = s.storage.ResolveRoot(hashID)
|
hashID = s.storage.ResolveRoot(hashID)
|
||||||
|
|
||||||
metadata, err := s.storage.GetMetadataFlat(hashID)
|
metadata, err := s.storage.GetMetadataFlatAs(hashID, r.URL.Query().Get("as"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -244,87 +247,6 @@ func (s *Server) handleGetMetadataByHashId(w http.ResponseWriter, r *http.Reques
|
|||||||
writeJSON(w, http.StatusOK, response)
|
writeJSON(w, http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUpdateMetadataByHashId handles PUT /api/metadata/{hashId}
|
|
||||||
func (s *Server) handleUpdateMetadataByHashId(w http.ResponseWriter, r *http.Request) {
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
hashID := vars["hashId"]
|
|
||||||
|
|
||||||
if hashID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "hash ID is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The {hashId} in the URL is whatever CID the caller knows the file by —
|
|
||||||
// the gateway's auto-store keys this PUT by the hash it just computed
|
|
||||||
// (dispatch.rs → store_metadata_only), which for a usenet→IPFS fetch is
|
|
||||||
// the dag-pb root, not the nzb-release cid the record was minted under.
|
|
||||||
// Without resolving, the write lands on file:<thatCid>/* and forks a
|
|
||||||
// second root for a file that already has one: a full duplicate record,
|
|
||||||
// cross-linked and separately indexed, which every downstream ingest then
|
|
||||||
// sees as two files. Unknown CIDs pass through unchanged, so a genuinely
|
|
||||||
// new gateway record still mints its root at the hash.
|
|
||||||
hashID = s.storage.ResolveRoot(hashID)
|
|
||||||
|
|
||||||
var metadata map[string]string
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exclude internal processing status field
|
|
||||||
delete(metadata, "processingStatus")
|
|
||||||
|
|
||||||
if err := s.storage.SetMetadataFlat(hashID, metadata); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"status": "ok",
|
|
||||||
"hashId": hashID,
|
|
||||||
"message": "Metadata updated successfully",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDeleteMetadataByHashId handles DELETE /api/metadata/{hashId}
|
|
||||||
func (s *Server) handleDeleteMetadataByHashId(w http.ResponseWriter, r *http.Request) {
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
hashID := vars["hashId"]
|
|
||||||
|
|
||||||
if hashID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "hash ID is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// meta-gateway retracts a dropped hit by whichever CID it holds
|
|
||||||
// (meta_core.rs `delete`, which documents this resolution as a
|
|
||||||
// precondition). Without it, deleting by a non-root CID is a silent
|
|
||||||
// no-op and the retracted record lingers.
|
|
||||||
hashID = s.storage.ResolveRoot(hashID)
|
|
||||||
|
|
||||||
deletedCount, err := s.storage.DeleteMetadata(hashID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"status": "ok",
|
|
||||||
"hashId": hashID,
|
|
||||||
"deletedKeys": deletedCount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleMetadataGetProperty handles GET /api/metadata/{hashId}/property?property=X
|
// handleMetadataGetProperty handles GET /api/metadata/{hashId}/property?property=X
|
||||||
func (s *Server) handleMetadataGetProperty(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleMetadataGetProperty(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
@@ -364,46 +286,6 @@ func (s *Server) handleMetadataGetProperty(w http.ResponseWriter, r *http.Reques
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMetadataUpdateProperty handles PUT /api/metadata/{hashId}/property
|
|
||||||
func (s *Server) handleMetadataUpdateProperty(w http.ResponseWriter, r *http.Request) {
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
hashID := vars["hashId"]
|
|
||||||
|
|
||||||
if hashID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "hash ID is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req PropertyUpdateRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Property == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "property is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.storage.SetProperty(hashID, req.Property, req.Value); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"status": "ok",
|
|
||||||
"hashId": hashID,
|
|
||||||
"property": req.Property,
|
|
||||||
"value": req.Value,
|
|
||||||
"message": "Property updated successfully",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSearchMetadata handles POST /api/metadata/search
|
// handleSearchMetadata handles POST /api/metadata/search
|
||||||
func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.storage.IsConnected() {
|
||||||
@@ -420,6 +302,9 @@ func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Limit == 0 {
|
if req.Limit == 0 {
|
||||||
req.Limit = 100
|
req.Limit = 100
|
||||||
}
|
}
|
||||||
|
if req.As == "" {
|
||||||
|
req.As = r.URL.Query().Get("as")
|
||||||
|
}
|
||||||
|
|
||||||
// If hashId is provided, do exact match
|
// If hashId is provided, do exact match
|
||||||
if req.HashID != "" {
|
if req.HashID != "" {
|
||||||
@@ -445,9 +330,8 @@ func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Everything else — free text, structured filters, both, or neither
|
// Everything else — free text, structured filters, both, or neither
|
||||||
// (which returns the first `limit`) — is served from the in-memory search
|
// (which returns the first `limit`) — is served from the store's derived
|
||||||
// index (search_index.go). Matching and the returned metadata both come
|
// search indexes (search_index.go → storage.Search).
|
||||||
// from memory, so a query costs zero Redis round-trips.
|
|
||||||
//
|
//
|
||||||
// There is deliberately no second path. The property branch used to fall
|
// There is deliberately no second path. The property branch used to fall
|
||||||
// through to a `GetAllHashIDs` + per-record `GetMetadataFlat` scan, which
|
// through to a `GetAllHashIDs` + per-record `GetMetadataFlat` scan, which
|
||||||
@@ -458,83 +342,6 @@ func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.serveIndexedSearch(w, req)
|
s.serveIndexedSearch(w, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleBatchUpdate handles POST /api/metadata/batch
|
|
||||||
func (s *Server) handleBatchUpdate(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req MetadataBatchUpdateRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(req.Updates) == 0 {
|
|
||||||
writeError(w, http.StatusBadRequest, "updates array is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var results []MetadataBatchResult
|
|
||||||
successCount := 0
|
|
||||||
errorCount := 0
|
|
||||||
|
|
||||||
for _, update := range req.Updates {
|
|
||||||
if update.HashID == "" {
|
|
||||||
results = append(results, MetadataBatchResult{
|
|
||||||
HashID: "unknown",
|
|
||||||
Status: "error",
|
|
||||||
Error: "Missing hash ID",
|
|
||||||
})
|
|
||||||
errorCount++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var updateErr error
|
|
||||||
|
|
||||||
// Update complete metadata
|
|
||||||
if update.Metadata != nil {
|
|
||||||
// Exclude internal processing status
|
|
||||||
delete(update.Metadata, "processingStatus")
|
|
||||||
updateErr = s.storage.SetMetadataFlat(update.HashID, update.Metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update individual properties
|
|
||||||
if updateErr == nil && update.Properties != nil {
|
|
||||||
for property, value := range update.Properties {
|
|
||||||
if err := s.storage.SetProperty(update.HashID, property, value); err != nil {
|
|
||||||
updateErr = err
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if updateErr != nil {
|
|
||||||
results = append(results, MetadataBatchResult{
|
|
||||||
HashID: update.HashID,
|
|
||||||
Status: "error",
|
|
||||||
Error: updateErr.Error(),
|
|
||||||
})
|
|
||||||
errorCount++
|
|
||||||
} else {
|
|
||||||
results = append(results, MetadataBatchResult{
|
|
||||||
HashID: update.HashID,
|
|
||||||
Status: "ok",
|
|
||||||
})
|
|
||||||
successCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, MetadataBatchResponse{
|
|
||||||
Status: "completed",
|
|
||||||
Total: len(results),
|
|
||||||
Success: successCount,
|
|
||||||
Errors: errorCount,
|
|
||||||
Results: results,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleClearMetadata handles POST /api/metadata/clear
|
// handleClearMetadata handles POST /api/metadata/clear
|
||||||
func (s *Server) handleClearMetadata(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleClearMetadata(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.storage.IsConnected() {
|
||||||
|
|||||||
@@ -3,43 +3,46 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleSchemaGet returns the live per-field schema derived from meta:events.
|
// handleSchemaGet returns the per-field schema, folded from counters the write
|
||||||
|
// path keeps exact (see schema.Indexer).
|
||||||
func (s *Server) handleSchemaGet(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSchemaGet(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.schemaIndexer == nil {
|
if s.schemaIndexer == nil {
|
||||||
http.Error(w, "schema indexer not running", http.StatusServiceUnavailable)
|
http.Error(w, "schema indexer not running", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
snap := s.schemaIndexer.Snapshot()
|
snap, err := s.schemaIndexer.Snapshot(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(snap)
|
_ = json.NewEncoder(w).Encode(snap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSchemaRescan re-emits a set event for every existing property via the
|
// handleSchemaRescan rebuilds the schema counters from a full read of the
|
||||||
// meta publisher, waits for the indexer to drain, and returns the refreshed
|
// records and returns the result. The counters are exact by construction, so
|
||||||
// schema. Equivalent to a full SCAN-based rebuild without locking Redis.
|
// this is a repair tool, not routine: it runs as one write transaction, and
|
||||||
|
// every other write waits for it (minutes on a large store).
|
||||||
func (s *Server) handleSchemaRescan(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSchemaRescan(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.schemaIndexer == nil {
|
if s.schemaIndexer == nil {
|
||||||
http.Error(w, "schema indexer not running", http.StatusServiceUnavailable)
|
http.Error(w, "schema indexer not running", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := s.storage.RebuildCounters(); err != nil {
|
||||||
count, err := s.RepublishMetadata()
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snap, err := s.schemaIndexer.Snapshot(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.schemaIndexer.WaitForDrain(10 * time.Second)
|
|
||||||
|
|
||||||
snap := s.schemaIndexer.Snapshot()
|
|
||||||
snap.Source = "rescan"
|
snap.Source = "rescan"
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"published_events": count,
|
"published_events": 0,
|
||||||
"schema": snap,
|
"schema": snap,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-195
@@ -1,221 +1,59 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/metazla/meta-core/internal/storage"
|
"github.com/metazla/meta-core/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// searchIndexRefreshInterval is how often the in-memory search index is rebuilt
|
// serveIndexedSearch answers a search from the database's derived indexes
|
||||||
// from Redis in the background. Records ingest continuously, so a newly stored
|
// (storage.Search): SQL narrows to a superset of candidates through the field
|
||||||
// record becomes searchable within one interval; the rebuild is a single
|
// expression index and the FTS5 trigram table, then every candidate is checked
|
||||||
// batched bulk read (storage.GetSearchIndexTuples), off the request path.
|
// here with the exact matchers below — a token-AND match against the record's
|
||||||
const searchIndexRefreshInterval = 60 * time.Second
|
// haystack (or a whole-query hashID substring, preserving the legacy
|
||||||
|
// behaviour) AND every structured filter.
|
||||||
// searchIndexReconcileEvery is how many refresh ticks pass between full
|
|
||||||
// rebuilds. Every other tick is incremental. 30 ticks × 60 s = a full
|
|
||||||
// reconcile every 30 minutes — see runRefreshLoop for why one is kept at all.
|
|
||||||
const searchIndexReconcileEvery = 30
|
|
||||||
|
|
||||||
// searchIndex is an in-memory snapshot of every record's search haystack
|
|
||||||
// (hashID + lowercased searchable fields), kept current from Redis. It exists
|
|
||||||
// so POST /api/metadata/search matches in memory (string ops over a slice)
|
|
||||||
// instead of doing a SCAN+MGET Redis round-trip per record — the latter took
|
|
||||||
// 40s+ over ~72k records and made a live parallel search path unusable.
|
|
||||||
//
|
//
|
||||||
// # Two update paths, and why
|
// Until 2026-10 this ran against an in-memory copy of every record, rebuilt
|
||||||
//
|
// from Redis; that copy cost ~30 KB of RAM per record (5.1 GB on watch.nsl.sh)
|
||||||
// `refresh` rebuilds everything: a whole-keyspace SCAN, batched MGETs, and a
|
// and is gone. The matchers did not change, so neither did the semantics.
|
||||||
// complete fresh copy. That is O(corpus) regardless of how much changed, and it
|
|
||||||
// used to run every 60 s forever. Measured on a 19 622-record staging corpus
|
|
||||||
// (2026-08-30): the loop accounted for ~45 % of ALL Redis CPU, and the Go side
|
|
||||||
// held 783 MB for data Redis stores in 135 MB — largely the double copy.
|
|
||||||
//
|
|
||||||
// `refreshIncremental` re-reads only the records that changed, tracked by
|
|
||||||
// storage's dirty set (search_dirty.go). Cost is O(change). It is the steady
|
|
||||||
// state; the full rebuild runs once at startup (there is nothing to be
|
|
||||||
// incremental against) and then only as an explicit repair.
|
|
||||||
//
|
|
||||||
// The distinction matters because meta-core is the platform's permanent,
|
|
||||||
// additive store — every search hit on every gateway is retained — so a rebuild
|
|
||||||
// priced against the whole corpus is a cost that only ever grows.
|
|
||||||
type searchIndex struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
// byID is the authoritative content; tuples is a stable-order view of it
|
|
||||||
// rebuilt on each update. Map iteration order in Go is randomised, so
|
|
||||||
// serving a limited query straight off the map would return a different
|
|
||||||
// subset each call — the slice is what keeps results stable.
|
|
||||||
byID map[string]storage.SearchTuple
|
|
||||||
tuples []storage.SearchTuple
|
|
||||||
ready atomic.Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// snapshot returns the current tuples slice and whether the index has built at
|
|
||||||
// least once. Every update swaps in a fresh slice under the write lock (never
|
|
||||||
// mutates in place), so the returned slice is safe to read lock-free.
|
|
||||||
func (idx *searchIndex) snapshot() ([]storage.SearchTuple, bool) {
|
|
||||||
idx.mu.RLock()
|
|
||||||
defer idx.mu.RUnlock()
|
|
||||||
return idx.tuples, idx.ready.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// rebuildViewLocked regenerates the stable-order slice from byID. Caller holds
|
|
||||||
// the write lock.
|
|
||||||
func (idx *searchIndex) rebuildViewLocked() {
|
|
||||||
view := make([]storage.SearchTuple, 0, len(idx.byID))
|
|
||||||
for _, t := range idx.byID {
|
|
||||||
view = append(view, t)
|
|
||||||
}
|
|
||||||
// Stable across rebuilds so a limited query returns a consistent page
|
|
||||||
// rather than a fresh random subset every interval.
|
|
||||||
sort.Slice(view, func(i, j int) bool { return view[i].HashID < view[j].HashID })
|
|
||||||
idx.tuples = view
|
|
||||||
}
|
|
||||||
|
|
||||||
// refresh rebuilds the whole index from Redis (a batched bulk read). Used for
|
|
||||||
// the initial build and as an explicit repair — not the steady state.
|
|
||||||
func (idx *searchIndex) refresh(stor *storage.Client) {
|
|
||||||
if stor == nil || !stor.IsConnected() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tuples, err := stor.GetSearchIndexTuples()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[API] search index refresh failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
byID := make(map[string]storage.SearchTuple, len(tuples))
|
|
||||||
for _, t := range tuples {
|
|
||||||
byID[t.HashID] = t
|
|
||||||
}
|
|
||||||
idx.mu.Lock()
|
|
||||||
idx.byID = byID
|
|
||||||
idx.rebuildViewLocked()
|
|
||||||
idx.mu.Unlock()
|
|
||||||
idx.ready.Store(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshIncremental drains the dirty set and patches only those records.
|
|
||||||
// Returns whether more dirty entries remain, so a burst is caught up over
|
|
||||||
// consecutive passes rather than one per interval.
|
|
||||||
//
|
|
||||||
// A record in the dirty set but absent from storage was deleted and is evicted
|
|
||||||
// here — otherwise it would keep answering searches until the next full
|
|
||||||
// rebuild. A batch that fails after the (destructive) drain is re-marked by
|
|
||||||
// storage so it is retried rather than silently lost.
|
|
||||||
func (idx *searchIndex) refreshIncremental(stor *storage.Client) (more bool) {
|
|
||||||
if stor == nil || !stor.IsConnected() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ids, more, err := stor.DrainDirty()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[API] search index drain failed: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
tuples, gone, err := stor.GetSearchIndexTuplesFor(ids)
|
|
||||||
if err != nil {
|
|
||||||
stor.MarkDirty(ids...)
|
|
||||||
log.Printf("[API] incremental search index refresh failed: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
idx.mu.Lock()
|
|
||||||
if idx.byID == nil {
|
|
||||||
idx.byID = make(map[string]storage.SearchTuple, len(tuples))
|
|
||||||
}
|
|
||||||
for _, t := range tuples {
|
|
||||||
idx.byID[t.HashID] = t
|
|
||||||
}
|
|
||||||
for _, id := range gone {
|
|
||||||
delete(idx.byID, id)
|
|
||||||
}
|
|
||||||
idx.rebuildViewLocked()
|
|
||||||
idx.mu.Unlock()
|
|
||||||
|
|
||||||
log.Printf("[API] search index: %d updated, %d evicted (%d records)", len(tuples), len(gone), len(idx.tuples))
|
|
||||||
return more
|
|
||||||
}
|
|
||||||
|
|
||||||
// runRefreshLoop builds the index once in full, then keeps it current from the
|
|
||||||
// dirty set, with a periodic full reconcile as a safety net.
|
|
||||||
//
|
|
||||||
// The reconcile exists because a *missed* dirty mark is silent: no error, just
|
|
||||||
// a record whose indexed copy quietly diverges from storage until something
|
|
||||||
// forces a rebuild. Marking is spread across eight write paths plus anything
|
|
||||||
// added later, and the cost of getting it wrong is invisible — so the index
|
|
||||||
// should not depend on that coverage being perfect forever. Every
|
|
||||||
// searchIndexReconcileEvery-th tick pays the old O(corpus) pass and repairs
|
|
||||||
// whatever drifted.
|
|
||||||
//
|
|
||||||
// That still drops the full rebuild from every 60 s to every 30 min — a ~30×
|
|
||||||
// reduction in the steady-state cost that measured at ~45 % of all Redis CPU —
|
|
||||||
// while keeping the self-healing property that made the naive loop safe.
|
|
||||||
func (idx *searchIndex) runRefreshLoop(stor *storage.Client) {
|
|
||||||
idx.refresh(stor)
|
|
||||||
ticker := time.NewTicker(searchIndexRefreshInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
ticks := 0
|
|
||||||
for range ticker.C {
|
|
||||||
ticks++
|
|
||||||
if ticks%searchIndexReconcileEvery == 0 {
|
|
||||||
idx.refresh(stor)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Drain until caught up, so a burst larger than one batch does not take
|
|
||||||
// one interval per batch to land.
|
|
||||||
for idx.refreshIncremental(stor) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// serveIndexedSearch answers a search from the in-memory index: a token-AND
|
|
||||||
// match against each record's haystack (or a whole-query hashID substring,
|
|
||||||
// preserving the legacy behaviour) AND every structured filter, then returns
|
|
||||||
// the full metadata for the <=limit survivors. No Redis round-trip per scanned
|
|
||||||
// record — matching and results both come from memory.
|
|
||||||
//
|
//
|
||||||
// This is the ONLY search path. Free text, filters, both, or neither all land
|
// This is the ONLY search path. Free text, filters, both, or neither all land
|
||||||
// here; only the `hashId` exact-match branch in handleSearchMetadata bypasses it.
|
// here; only the `hashId` exact-match branch in handleSearchMetadata bypasses it.
|
||||||
func (s *Server) serveIndexedSearch(w http.ResponseWriter, req MetadataSearchRequest) {
|
func (s *Server) serveIndexedSearch(w http.ResponseWriter, req MetadataSearchRequest) {
|
||||||
tuples, ready := s.search.snapshot()
|
|
||||||
if !ready {
|
|
||||||
// First call before the background build finished: warm inline with the
|
|
||||||
// efficient batched read (seconds), never the per-record SCAN fallback.
|
|
||||||
s.search.refresh(s.storage)
|
|
||||||
tuples, _ = s.search.snapshot()
|
|
||||||
}
|
|
||||||
tokens := strings.Fields(strings.ToLower(req.Query))
|
tokens := strings.Fields(strings.ToLower(req.Query))
|
||||||
queryLower := strings.ToLower(req.Query)
|
queryLower := strings.ToLower(req.Query)
|
||||||
filters := normalizedFilters(req)
|
filters := normalizedFilters(req)
|
||||||
results := make([]MetadataSearchResult, 0, req.Limit)
|
|
||||||
for _, t := range tuples {
|
narrow := make([]storage.SearchFilter, 0, len(filters))
|
||||||
if len(results) >= req.Limit {
|
for _, f := range filters {
|
||||||
break
|
narrow = append(narrow, storage.SearchFilter{Property: f.Property, Values: f.Values})
|
||||||
}
|
}
|
||||||
|
hits, err := s.storage.SearchAs(req.Query, narrow, req.Limit, req.As, func(hashID string, fields map[string]string) bool {
|
||||||
if req.Query != "" &&
|
if req.Query != "" &&
|
||||||
!matchAllTokens(t.Haystack, tokens) &&
|
!matchAllTokens(storage.Haystack(fields), tokens) &&
|
||||||
!strings.Contains(strings.ToLower(t.HashID), queryLower) {
|
!strings.Contains(strings.ToLower(hashID), queryLower) {
|
||||||
continue
|
return false
|
||||||
}
|
}
|
||||||
if !recordMatchesFilters(t.Fields, filters) {
|
return recordMatchesFilters(fields, filters)
|
||||||
continue
|
})
|
||||||
}
|
if err != nil {
|
||||||
// Full metadata is cached in the index — return it straight from memory,
|
writeError(w, http.StatusInternalServerError, "search failed: "+err.Error())
|
||||||
// no Redis round-trip per result.
|
return
|
||||||
results = append(results, MetadataSearchResult{HashID: t.HashID, Metadata: t.Fields})
|
}
|
||||||
|
total, err := s.storage.CountRoots()
|
||||||
|
if err != nil {
|
||||||
|
total = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]MetadataSearchResult, 0, len(hits))
|
||||||
|
for _, h := range hits {
|
||||||
|
results = append(results, MetadataSearchResult{HashID: h.HashID, Metadata: h.Fields})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, MetadataSearchResponse{
|
writeJSON(w, http.StatusOK, MetadataSearchResponse{
|
||||||
Results: results,
|
Results: results,
|
||||||
Count: len(results),
|
Count: len(results),
|
||||||
Total: len(tuples),
|
Total: int(total),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
-117
@@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/metazla/meta-core/internal/config"
|
"github.com/metazla/meta-core/internal/config"
|
||||||
"github.com/metazla/meta-core/internal/events"
|
|
||||||
"github.com/metazla/meta-core/internal/identity"
|
"github.com/metazla/meta-core/internal/identity"
|
||||||
"github.com/metazla/meta-core/internal/leader"
|
"github.com/metazla/meta-core/internal/leader"
|
||||||
"github.com/metazla/meta-core/internal/meshdisco"
|
"github.com/metazla/meta-core/internal/meshdisco"
|
||||||
@@ -36,10 +35,8 @@ type Server struct {
|
|||||||
watchersManager *watchers.Manager
|
watchersManager *watchers.Manager
|
||||||
watchersPoller *watchers.Poller
|
watchersPoller *watchers.Poller
|
||||||
watchersHandlers *watchers.Handlers
|
watchersHandlers *watchers.Handlers
|
||||||
metaPublisher *events.MetaPublisher
|
|
||||||
schemaIndexer *schema.Indexer
|
schemaIndexer *schema.Indexer
|
||||||
webdavHandler *webdav.Handler
|
webdavHandler *webdav.Handler
|
||||||
search *searchIndex
|
|
||||||
// challenges backs proof-of-possession on reveal/delete. In-process, so a
|
// challenges backs proof-of-possession on reveal/delete. In-process, so a
|
||||||
// restart invalidates the ones in flight — see identity/challenge.go.
|
// restart invalidates the ones in flight — see identity/challenge.go.
|
||||||
challenges *identity.ChallengeStore
|
challenges *identity.ChallengeStore
|
||||||
@@ -51,7 +48,7 @@ type Server struct {
|
|||||||
router *mux.Router
|
router *mux.Router
|
||||||
server *http.Server
|
server *http.Server
|
||||||
// stats caches the keyspace-walk half of GET /api/stats so a polling
|
// stats caches the keyspace-walk half of GET /api/stats so a polling
|
||||||
// dashboard never puts a multi-second Redis walk on the request path.
|
// dashboard never puts a multi-second store walk on the request path.
|
||||||
stats *statsCache
|
stats *statsCache
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +68,6 @@ func NewServer(
|
|||||||
config: cfg,
|
config: cfg,
|
||||||
leaderProvider: leaderProvider,
|
leaderProvider: leaderProvider,
|
||||||
storage: stor,
|
storage: stor,
|
||||||
search: &searchIndex{},
|
|
||||||
router: mux.NewRouter(),
|
router: mux.NewRouter(),
|
||||||
stats: &statsCache{},
|
stats: &statsCache{},
|
||||||
}
|
}
|
||||||
@@ -80,7 +76,7 @@ func NewServer(
|
|||||||
var scanFunc mounts.ScanFunc
|
var scanFunc mounts.ScanFunc
|
||||||
if cfg.EnableFileWatcher {
|
if cfg.EnableFileWatcher {
|
||||||
s.watcherDispatcher = watcher.NewDispatcher()
|
s.watcherDispatcher = watcher.NewDispatcher()
|
||||||
// Wire storage client for Redis Stream publishing
|
// Wire storage client for file:events publishing
|
||||||
if stor != nil {
|
if stor != nil {
|
||||||
s.watcherDispatcher.SetStorageClient(stor)
|
s.watcherDispatcher.SetStorageClient(stor)
|
||||||
}
|
}
|
||||||
@@ -90,7 +86,7 @@ func NewServer(
|
|||||||
} else {
|
} else {
|
||||||
s.fileWatcher = fileWatcher
|
s.fileWatcher = fileWatcher
|
||||||
// Wire storage so the watcher can persist (path,size,mtime,cid)
|
// Wire storage so the watcher can persist (path,size,mtime,cid)
|
||||||
// tuples to Redis as it computes midhashes.
|
// tuples to the store as it computes midhashes.
|
||||||
if stor != nil {
|
if stor != nil {
|
||||||
fileWatcher.SetStorageClient(stor)
|
fileWatcher.SetStorageClient(stor)
|
||||||
}
|
}
|
||||||
@@ -109,10 +105,9 @@ func NewServer(
|
|||||||
// Create watchers poller
|
// Create watchers poller
|
||||||
if s.fileWatcher != nil {
|
if s.fileWatcher != nil {
|
||||||
s.watchersPoller = watchers.NewPoller(watchersManager, s.fileWatcher, s.watcherDispatcher)
|
s.watchersPoller = watchers.NewPoller(watchersManager, s.fileWatcher, s.watcherDispatcher)
|
||||||
// Pass republish callback to handlers (handles nil metaPublisher case)
|
s.watchersHandlers = watchers.NewHandlers(watchersManager, s.watchersPoller)
|
||||||
s.watchersHandlers = watchers.NewHandlers(watchersManager, s.watchersPoller, s.RepublishMetadata)
|
|
||||||
|
|
||||||
// Hydrate the in-memory state registry from Redis tuples so
|
// Hydrate the in-memory state registry from stored tuples so
|
||||||
// the first scan tick after boot can skip ComputeMidHash256
|
// the first scan tick after boot can skip ComputeMidHash256
|
||||||
// for files whose (size, mtime) match.
|
// for files whose (size, mtime) match.
|
||||||
configs := watchersManager.List()
|
configs := watchersManager.List()
|
||||||
@@ -123,7 +118,7 @@ func NewServer(
|
|||||||
if loaded, skipped, err := s.fileWatcher.HydrateStateFromStorage(roots); err != nil {
|
if loaded, skipped, err := s.fileWatcher.HydrateStateFromStorage(roots); err != nil {
|
||||||
log.Printf("[API] Warning: failed to hydrate watcher state: %v", err)
|
log.Printf("[API] Warning: failed to hydrate watcher state: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[API] Hydrated watcher state from Redis: %d files loaded, %d skipped (no matching watcher root)", loaded, skipped)
|
log.Printf("[API] Hydrated watcher state from the store: %d files loaded, %d skipped (no matching watcher root)", loaded, skipped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +176,7 @@ func (s *Server) setupRoutes() {
|
|||||||
s.router.HandleFunc("/api/metadata/hash-ids", s.handleGetHashIds).Methods("GET")
|
s.router.HandleFunc("/api/metadata/hash-ids", s.handleGetHashIds).Methods("GET")
|
||||||
s.router.HandleFunc("/api/metadata/list", s.handleListMetadata).Methods("GET")
|
s.router.HandleFunc("/api/metadata/list", s.handleListMetadata).Methods("GET")
|
||||||
s.router.HandleFunc("/api/metadata/search", s.handleSearchMetadata).Methods("POST")
|
s.router.HandleFunc("/api/metadata/search", s.handleSearchMetadata).Methods("POST")
|
||||||
s.router.HandleFunc("/api/metadata/batch", s.handleBatchUpdate).Methods("POST")
|
s.router.HandleFunc("/api/metadata/batch", s.handleGone).Methods("POST")
|
||||||
s.router.HandleFunc("/api/metadata/clear", s.handleClearMetadata).Methods("POST")
|
s.router.HandleFunc("/api/metadata/clear", s.handleClearMetadata).Methods("POST")
|
||||||
|
|
||||||
// User Data Layer signing identity (see internal/identity). Auth-gated
|
// User Data Layer signing identity (see internal/identity). Auth-gated
|
||||||
@@ -197,15 +192,18 @@ func (s *Server) setupRoutes() {
|
|||||||
s.router.HandleFunc("/api/identity/sign-batch", s.handleIdentitySignBatch).Methods("POST")
|
s.router.HandleFunc("/api/identity/sign-batch", s.handleIdentitySignBatch).Methods("POST")
|
||||||
s.router.HandleFunc("/api/identity/aead-key", s.handleIdentityAEADKey).Methods("GET")
|
s.router.HandleFunc("/api/identity/aead-key", s.handleIdentityAEADKey).Methods("GET")
|
||||||
|
|
||||||
// Snapshot (export / import / wipe)
|
// Signed claims (v2.0.0): the only metadata write path, the raw claims
|
||||||
s.router.HandleFunc("/api/snapshot/export", s.handleSnapshotExport).Methods("GET")
|
// behind a record, and the stateless merge clients use for search hits.
|
||||||
s.router.HandleFunc("/api/snapshot/import", s.handleSnapshotImport).Methods("POST")
|
s.router.HandleFunc("/api/claims", s.handlePostClaims).Methods("POST")
|
||||||
s.router.HandleFunc("/api/snapshot/wipe", s.handleSnapshotWipe).Methods("POST")
|
s.router.HandleFunc("/api/merge", s.handleMerge).Methods("POST")
|
||||||
|
s.router.HandleFunc("/api/meta/{cid}/claims", s.handleClaimsByCID).Methods("GET")
|
||||||
|
s.router.HandleFunc("/api/metadata/{hashId}/claims", s.handleClaimsByRID).Methods("GET")
|
||||||
|
|
||||||
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataGetProperty).Methods("GET")
|
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataGetProperty).Methods("GET")
|
||||||
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataUpdateProperty).Methods("PUT")
|
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleGone).Methods("PUT")
|
||||||
s.router.HandleFunc("/api/metadata/{hashId}", s.handleGetMetadataByHashId).Methods("GET")
|
s.router.HandleFunc("/api/metadata/{hashId}", s.handleGetMetadataByHashId).Methods("GET")
|
||||||
s.router.HandleFunc("/api/metadata/{hashId}", s.handleUpdateMetadataByHashId).Methods("PUT")
|
s.router.HandleFunc("/api/metadata/{hashId}", s.handleGone).Methods("PUT")
|
||||||
s.router.HandleFunc("/api/metadata/{hashId}", s.handleDeleteMetadataByHashId).Methods("DELETE")
|
s.router.HandleFunc("/api/metadata/{hashId}", s.handleGone).Methods("DELETE")
|
||||||
|
|
||||||
// Schema inference
|
// Schema inference
|
||||||
s.router.HandleFunc("/api/schema", s.handleSchemaGet).Methods("GET")
|
s.router.HandleFunc("/api/schema", s.handleSchemaGet).Methods("GET")
|
||||||
@@ -237,18 +235,15 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
// Metadata operations - base endpoints
|
// Metadata operations - base endpoints
|
||||||
s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET")
|
s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET")
|
||||||
s.router.HandleFunc("/meta/{hash}", s.handlePutMeta).Methods("PUT")
|
s.router.HandleFunc("/meta/{hash}", s.handleGone).Methods("PUT", "PATCH", "DELETE")
|
||||||
s.router.HandleFunc("/meta/{hash}", s.handlePatchMeta).Methods("PATCH")
|
|
||||||
s.router.HandleFunc("/meta/{hash}", s.handleDeleteMeta).Methods("DELETE")
|
|
||||||
s.router.HandleFunc("/meta", s.handleListMeta).Methods("GET")
|
s.router.HandleFunc("/meta", s.handleListMeta).Methods("GET")
|
||||||
|
|
||||||
// Metadata operations - set operations (must be before property routes)
|
// Metadata operations - set operations (must be before property routes)
|
||||||
s.router.HandleFunc("/meta/{hash}/_add/{key:.*}", s.handleAddToSet).Methods("POST")
|
s.router.HandleFunc("/meta/{hash}/_add/{key:.*}", s.handleGone).Methods("POST")
|
||||||
|
|
||||||
// Metadata operations - property-level (key can contain slashes)
|
// Metadata operations - property-level (key can contain slashes)
|
||||||
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleGetProperty).Methods("GET")
|
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleGetProperty).Methods("GET")
|
||||||
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handlePutProperty).Methods("PUT")
|
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleGone).Methods("PUT", "DELETE")
|
||||||
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleDeleteProperty).Methods("DELETE")
|
|
||||||
|
|
||||||
// Data operations
|
// Data operations
|
||||||
s.router.HandleFunc("/data/{hash}/path", s.handleGetDataPath).Methods("GET")
|
s.router.HandleFunc("/data/{hash}/path", s.handleGetDataPath).Methods("GET")
|
||||||
@@ -272,20 +267,8 @@ func (s *Server) setupRoutes() {
|
|||||||
// without going through Authelia.
|
// without going through Authelia.
|
||||||
s.router.HandleFunc("/api/meta/{cid}", s.handleGetMetaByCID).Methods("GET")
|
s.router.HandleFunc("/api/meta/{cid}", s.handleGetMetaByCID).Methods("GET")
|
||||||
|
|
||||||
// Admin: reunify stranded midhash-rooted entries into their UUID roots
|
// SSE event streams. The only way consumers see meta-core's change
|
||||||
// (the dual-root pattern caused by historical writes that bypassed CID
|
// streams — they never touch the database. Auth-bypass
|
||||||
// resolution).
|
|
||||||
s.router.HandleFunc("/api/admin/migrate-dual-roots", s.handleMigrateDualRoots).Methods("POST")
|
|
||||||
// One-shot vocabulary sweep: domain film|tv → screen, workForm backfill
|
|
||||||
// (METADATA_KEYS.md §14.17). Idempotent.
|
|
||||||
s.router.HandleFunc("/api/admin/migrate-domain-screen", s.handleMigrateDomainScreen).Methods("POST")
|
|
||||||
s.router.HandleFunc("/api/admin/migrate-user-cids", s.handleMigrateUserCids).Methods("POST")
|
|
||||||
// One-shot sweep of query echoes on literature cards (meta-read
|
|
||||||
// reading-model.md §7). Dry-run unless ?apply=true. Idempotent.
|
|
||||||
s.router.HandleFunc("/api/admin/sweep-literature-echoes", s.handleSweepLiteratureEchoes).Methods("POST")
|
|
||||||
|
|
||||||
// SSE event streams. Mediated mirror of the Redis Streams; consumers
|
|
||||||
// reach them over HTTP instead of touching Redis directly. Auth-bypass
|
|
||||||
// applies but exposure should be inside-only (do NOT proxy through
|
// applies but exposure should be inside-only (do NOT proxy through
|
||||||
// Caddy publicly). See docs/api-mediated-access.md "Auth".
|
// Caddy publicly). See docs/api-mediated-access.md "Auth".
|
||||||
s.router.HandleFunc("/api/events/files", s.handleEventsFiles).Methods("GET")
|
s.router.HandleFunc("/api/events/files", s.handleEventsFiles).Methods("GET")
|
||||||
@@ -363,54 +346,22 @@ func (s *Server) Start() error {
|
|||||||
s.mountStatsPoller.Start()
|
s.mountStatsPoller.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build + periodically refresh the in-memory search index so
|
// Schema: served from counters the write path keeps exact; nothing runs.
|
||||||
// POST /api/metadata/search matches in memory instead of a Redis
|
// Change events need no publisher either — the storage engine appends them
|
||||||
// round-trip per record (see search_index.go).
|
// in the same transaction as each change, and a consumer that wants the
|
||||||
if s.storage != nil {
|
// whole state asks /api/events/meta for "0" and gets a snapshot (see
|
||||||
go s.search.runRefreshLoop(s.storage)
|
// streamSnapshot). The boot republish of every field is gone with it.
|
||||||
}
|
|
||||||
|
|
||||||
// Start meta publisher (if storage is connected)
|
|
||||||
// Publishes keyspace notifications to meta:events stream
|
|
||||||
if s.storage != nil && s.storage.IsConnected() {
|
if s.storage != nil && s.storage.IsConnected() {
|
||||||
s.metaPublisher = events.NewMetaPublisher(s.storage.GetRedisClient())
|
s.schemaIndexer = schema.NewIndexer(s.storage.Store())
|
||||||
if err := s.metaPublisher.Start(); err != nil {
|
go func() {
|
||||||
log.Printf("[API] Warning: failed to start meta publisher: %v", err)
|
started := time.Now()
|
||||||
} else {
|
if n, err := s.storage.RebuildSearchIfEmpty(); err != nil {
|
||||||
// Schema indexer consumes the same stream to derive a live
|
log.Printf("[API] Warning: search row rebuild failed: %v", err)
|
||||||
// per-field schema (type/hint/breakdown). Start it before the
|
} else if n > 0 {
|
||||||
// republish flood so it captures the bootstrap events.
|
log.Printf("[API] Rebuilt search rows for %d records in %s", n, time.Since(started).Round(time.Millisecond))
|
||||||
s.schemaIndexer = schema.NewIndexer(s.storage.GetRedisClient(), s.storage.GetPrefix())
|
|
||||||
if err := s.schemaIndexer.Start(); err != nil {
|
|
||||||
log.Printf("[API] Warning: failed to start schema indexer: %v", err)
|
|
||||||
s.schemaIndexer = nil
|
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
// Republish events for all existing metadata
|
go s.logWriteStats()
|
||||||
go func() {
|
|
||||||
// Build any missing per-record field indexes first, in ONE
|
|
||||||
// keyspace pass. The republish below reads every record, and
|
|
||||||
// an un-indexed record read costs a full-keyspace SCAN — on a
|
|
||||||
// real store that walk warmed at ~1.3 records/s (a ~2.6h tail,
|
|
||||||
// paid again on every restart). Warming first turns the whole
|
|
||||||
// republish into index-served reads. Idempotent, so a
|
|
||||||
// fully-indexed store just pays the single scan.
|
|
||||||
// See storage/field_index.go.
|
|
||||||
started := time.Now()
|
|
||||||
if n, err := s.storage.WarmFieldIndexes(); err != nil {
|
|
||||||
log.Printf("[API] Warning: field index warm failed: %v (reads fall back to SCAN)", err)
|
|
||||||
} else if n > 0 {
|
|
||||||
log.Printf("[API] Warmed %d field indexes in %s", n, time.Since(started).Round(time.Millisecond))
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := s.RepublishMetadata()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[API] Warning: failed to republish metadata on startup: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("[API] Republished %d metadata events on startup", count)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -437,20 +388,6 @@ func (s *Server) Stop() error {
|
|||||||
s.mountStatsPoller.Stop()
|
s.mountStatsPoller.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop schema indexer before the publisher so the consumer drains cleanly
|
|
||||||
if s.schemaIndexer != nil {
|
|
||||||
if err := s.schemaIndexer.Stop(); err != nil {
|
|
||||||
log.Printf("[API] Warning: failed to stop schema indexer: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop meta publisher
|
|
||||||
if s.metaPublisher != nil {
|
|
||||||
if err := s.metaPublisher.Stop(); err != nil {
|
|
||||||
log.Printf("[API] Warning: failed to stop meta publisher: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.server == nil {
|
if s.server == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -463,26 +400,22 @@ func (s *Server) Stop() error {
|
|||||||
return s.server.Shutdown(ctx)
|
return s.server.Shutdown(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepublishMetadata republishes all existing metadata to the meta:events stream
|
// logWriteStats reports, every 15 minutes, how many writes changed a value
|
||||||
// This is called on startup and after reset-all
|
// and how many found it already in place (kv skips those). The ratio is what
|
||||||
func (s *Server) RepublishMetadata() (int, error) {
|
// the no-op skip buys on a permanent store that re-persists repeated hits.
|
||||||
if s.metaPublisher == nil {
|
func (s *Server) logWriteStats() {
|
||||||
return 0, fmt.Errorf("meta publisher not initialized")
|
ticker := time.NewTicker(15 * time.Minute)
|
||||||
}
|
defer ticker.Stop()
|
||||||
if s.storage == nil || !s.storage.IsConnected() {
|
for range ticker.C {
|
||||||
return 0, fmt.Errorf("storage not connected")
|
store := s.storage.Store()
|
||||||
}
|
if store == nil {
|
||||||
|
return
|
||||||
// Create the republish function that wraps storage methods
|
}
|
||||||
republishFunc := func() ([]string, func(string) (map[string]string, error), error) {
|
changed, unchanged := store.WriteStats()
|
||||||
hashIDs, err := s.storage.GetAllHashIDs()
|
if total := changed + unchanged; total > 0 {
|
||||||
if err != nil {
|
log.Printf("[Storage] writes since start: %d changed, %d unchanged skipped (%.0f%% no-op)", changed, unchanged, 100*float64(unchanged)/float64(total))
|
||||||
return nil, nil, err
|
|
||||||
}
|
}
|
||||||
return hashIDs, s.storage.GetMetadataFlat, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.metaPublisher.RepublishAllMetadata(republishFunc)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// statusRecorder captures the response status so loggingMiddleware can stay
|
// statusRecorder captures the response status so loggingMiddleware can stay
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/metazla/meta-core/internal/snapshot"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Bound the upload size so a malicious client can't OOM the server.
|
|
||||||
// 2 GiB is well above any realistic export.
|
|
||||||
const maxImportBytes = 2 << 30
|
|
||||||
|
|
||||||
// pluginOutputDir is where plugin-produced artifacts live on disk
|
|
||||||
// (subtitles, posters, etc). Mounted from the host into the meta-core
|
|
||||||
// container at $FILES_PATH/plugin.
|
|
||||||
func (s *Server) pluginOutputDir() string {
|
|
||||||
return filepath.Join(s.config.FilesPath, "plugin")
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSnapshotExport streams a ZIP snapshot of all metadata.
|
|
||||||
// GET /api/snapshot/export?include=hash-index,plugin-output
|
|
||||||
func (s *Server) handleSnapshotExport(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The server has a 30s WriteTimeout (server.go:222). Long exports
|
|
||||||
// blow past that, leaving a truncated ZIP without a central directory.
|
|
||||||
// Clear the deadline for this handler so the export can run as long as
|
|
||||||
// needed. ResponseController is the standard way to do this.
|
|
||||||
rc := http.NewResponseController(w)
|
|
||||||
_ = rc.SetWriteDeadline(time.Time{})
|
|
||||||
|
|
||||||
include := splitCSVParam(r.URL.Query().Get("include"))
|
|
||||||
opts := snapshot.ExportOptions{
|
|
||||||
IncludePluginOutput: include["plugin-output"],
|
|
||||||
PluginOutputDir: s.pluginOutputDir(),
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := fmt.Sprintf("metamesh-snapshot-%s.zip", time.Now().UTC().Format("20060102-150405"))
|
|
||||||
w.Header().Set("Content-Type", "application/zip")
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
|
||||||
// We stream — no Content-Length.
|
|
||||||
|
|
||||||
exporter := snapshot.NewExporter(s.storage)
|
|
||||||
if _, err := exporter.Export(w, opts); err != nil {
|
|
||||||
// Headers may already be sent; log and bail.
|
|
||||||
// Client will see a truncated zip, which is the best signal we can give.
|
|
||||||
fmt.Printf("[Snapshot] export failed: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSnapshotImport applies an uploaded ZIP snapshot.
|
|
||||||
// POST /api/snapshot/import?mode=replace|merge&conflict=mine|source&dry_run=true
|
|
||||||
// Body: application/zip (raw) OR multipart/form-data with field "snapshot"
|
|
||||||
func (s *Server) handleSnapshotImport(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Long imports can blow past the server's 30s ReadTimeout/WriteTimeout
|
|
||||||
// when the snapshot is large. Clear both for this handler.
|
|
||||||
rc := http.NewResponseController(w)
|
|
||||||
_ = rc.SetReadDeadline(time.Time{})
|
|
||||||
_ = rc.SetWriteDeadline(time.Time{})
|
|
||||||
|
|
||||||
q := r.URL.Query()
|
|
||||||
opts := snapshot.ImportOptions{
|
|
||||||
Mode: snapshot.Mode(q.Get("mode")),
|
|
||||||
Conflict: snapshot.Conflict(q.Get("conflict")),
|
|
||||||
DryRun: strings.EqualFold(q.Get("dry_run"), "true"),
|
|
||||||
PluginOutputDir: s.pluginOutputDir(),
|
|
||||||
}
|
|
||||||
if opts.Mode == "" {
|
|
||||||
opts.Mode = snapshot.ModeMerge
|
|
||||||
}
|
|
||||||
if opts.Mode == snapshot.ModeMerge && opts.Conflict == "" {
|
|
||||||
opts.Conflict = snapshot.ConflictSource
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := readImportBody(r)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
importer := snapshot.NewImporter(s.storage)
|
|
||||||
result, err := importer.Import(bytes.NewReader(body), int64(len(body)), opts)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSnapshotWipe clears Redis metadata.
|
|
||||||
// POST /api/snapshot/wipe?scope=metadata
|
|
||||||
func (s *Server) handleSnapshotWipe(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.storage.IsConnected() {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scopes := splitCSVParam(r.URL.Query().Get("scope"))
|
|
||||||
if len(scopes) == 0 {
|
|
||||||
writeError(w, http.StatusBadRequest, "scope query param is required (e.g. scope=metadata)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scope := snapshot.WipeScope{
|
|
||||||
Metadata: scopes["metadata"],
|
|
||||||
}
|
|
||||||
if !scope.Metadata {
|
|
||||||
writeError(w, http.StatusBadRequest, "no supported scope requested; scope=metadata is the only accepted value")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
wiper := snapshot.NewWiper(s.storage)
|
|
||||||
res, err := wiper.Wipe(scope)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
// readImportBody accepts either a raw application/zip body or a multipart
|
|
||||||
// upload with a field named "snapshot". Buffers up to maxImportBytes.
|
|
||||||
func readImportBody(r *http.Request) ([]byte, error) {
|
|
||||||
r.Body = http.MaxBytesReader(nil, r.Body, maxImportBytes)
|
|
||||||
|
|
||||||
ct := r.Header.Get("Content-Type")
|
|
||||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
|
||||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse multipart: %w", err)
|
|
||||||
}
|
|
||||||
f, _, err := r.FormFile("snapshot")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("missing 'snapshot' file field: %w", err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
return io.ReadAll(f)
|
|
||||||
}
|
|
||||||
|
|
||||||
return io.ReadAll(r.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitCSVParam parses "a,b,c" into a set. Empty values yield an empty set.
|
|
||||||
func splitCSVParam(v string) map[string]bool {
|
|
||||||
out := map[string]bool{}
|
|
||||||
for _, part := range strings.Split(v, ",") {
|
|
||||||
p := strings.TrimSpace(part)
|
|
||||||
if p != "" {
|
|
||||||
out[p] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
+155
-93
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,15 +10,18 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/metazla/meta-core/internal/kv"
|
||||||
|
"github.com/metazla/meta-core/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SSE event stream endpoints for the file:events and meta:events Redis Streams.
|
// SSE event stream endpoints for the file:events and meta:events streams.
|
||||||
//
|
//
|
||||||
// Wire contract (see docs/api-mediated-access.md):
|
// Wire contract (see docs/api-mediated-access.md):
|
||||||
// - One SSE event per Redis Stream entry.
|
// - One SSE event per stream entry.
|
||||||
// - `id:` is the opaque Redis Stream entry ID (`<ms>-<seq>`); consumers
|
// - `id:` is the opaque stream entry ID (`<ms>-<seq>`, the Redis Streams
|
||||||
// MUST treat it as a black-box string they echo back via Last-Event-ID.
|
// form, kept across the move to SQLite so cursors consumers persisted to
|
||||||
|
// disk still compare correctly); consumers MUST treat it as a black-box
|
||||||
|
// string they echo back via Last-Event-ID.
|
||||||
// - `event:` is the `type` field from the underlying entry.
|
// - `event:` is the `type` field from the underlying entry.
|
||||||
// - `data:` is the JSON-encoded rest of the entry (all fields except `type`).
|
// - `data:` is the JSON-encoded rest of the entry (all fields except `type`).
|
||||||
// - Heartbeats are SSE comment lines (`:keep-alive\n\n`) every 30s of silence.
|
// - Heartbeats are SSE comment lines (`:keep-alive\n\n`) every 30s of silence.
|
||||||
@@ -30,30 +32,30 @@ import (
|
|||||||
const (
|
const (
|
||||||
sseHeartbeatInterval = 30 * time.Second
|
sseHeartbeatInterval = 30 * time.Second
|
||||||
|
|
||||||
// sseReadBlock controls how long each XREAD blocks before returning.
|
// sseReadBlock controls how long each read waits for new entries.
|
||||||
// Much shorter than sseHeartbeatInterval so the handler stays responsive
|
// Much shorter than sseHeartbeatInterval so the handler stays responsive
|
||||||
// to client disconnects and the heartbeat check runs frequently enough
|
// to client disconnects and the heartbeat check runs frequently enough
|
||||||
// that the keep-alive fires within a few seconds of its nominal cadence.
|
// that the keep-alive fires within a few seconds of its nominal cadence.
|
||||||
sseReadBlock = 2 * time.Second
|
sseReadBlock = 2 * time.Second
|
||||||
|
|
||||||
// sseReadCount caps how many entries one XREAD returns. Small enough
|
// sseReadCount caps how many entries one read returns. Small enough
|
||||||
// to keep per-flush memory low, large enough to drain bursts cheaply.
|
// to keep per-flush memory low, large enough to drain bursts cheaply.
|
||||||
sseReadCount = 100
|
sseReadCount = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleEventsFiles streams the file:events Redis Stream as SSE.
|
// handleEventsFiles streams the file:events stream as SSE.
|
||||||
func (s *Server) handleEventsFiles(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleEventsFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
s.streamSSE(w, r, "file:events")
|
s.streamSSE(w, r, "file:events")
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleEventsMeta streams the meta:events Redis Stream as SSE.
|
// handleEventsMeta streams the meta:events stream as SSE.
|
||||||
func (s *Server) handleEventsMeta(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleEventsMeta(w http.ResponseWriter, r *http.Request) {
|
||||||
s.streamSSE(w, r, "meta:events")
|
s.streamSSE(w, r, "meta:events")
|
||||||
}
|
}
|
||||||
|
|
||||||
// streamSSE is the shared SSE pump shared by both endpoints. One handler per
|
// streamSSE is the SSE pump shared by both endpoints. Each subscriber reads
|
||||||
// stream — there is no fan-out; each subscriber holds its own XREAD loop.
|
// the stream itself and sleeps on the store's commit broadcast between reads —
|
||||||
// That's fine at our scale (≤20 subscribers; one per consumer service).
|
// fine at our scale (≤20 subscribers; one per consumer service).
|
||||||
func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string) {
|
func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.storage.IsConnected() {
|
||||||
w.Header().Set("Retry-After", "5")
|
w.Header().Set("Retry-After", "5")
|
||||||
@@ -89,14 +91,37 @@ func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
|
|
||||||
client := s.storage.GetRedisClient()
|
store := s.storage.Store()
|
||||||
if client == nil {
|
if store == nil {
|
||||||
// Storage was just disconnected — close cleanly.
|
// Storage was just disconnected — close cleanly.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
cursor := resolveStartCursor(ctx, client, stream, r.Header.Get("Last-Event-ID"), w, flusher)
|
lastID := r.Header.Get("Last-Event-ID")
|
||||||
|
var cursor string
|
||||||
|
if stream == storage.MetaEventsStream && (lastID == "0" || lastID == "0-0") {
|
||||||
|
// "From the beginning" on meta:events means "the whole current
|
||||||
|
// state": stream it from the records themselves, then the live tail.
|
||||||
|
head, err := streamSnapshot(ctx, store, w, flusher)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == nil {
|
||||||
|
log.Printf("[SSE %s] snapshot failed: %v", stream, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cursor = head
|
||||||
|
} else {
|
||||||
|
if rebuilt := s.storage.RebuiltAt(); stream == storage.MetaEventsStream && lastID != "" && rebuilt != "" &&
|
||||||
|
compareStreamIDs(lastID, rebuilt) < 0 {
|
||||||
|
// The cache was rebuilt after this cursor: the rebuild published no
|
||||||
|
// per-key events, so the consumer must take a snapshot.
|
||||||
|
writeGap(w, flusher, lastID, rebuilt, "rebuild")
|
||||||
|
cursor = "0"
|
||||||
|
} else {
|
||||||
|
cursor = resolveStartCursor(ctx, store, stream, lastID, w, flusher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lastWrite := time.Now()
|
lastWrite := time.Now()
|
||||||
for {
|
for {
|
||||||
@@ -104,37 +129,34 @@ func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bounded BLOCK lets us re-check ctx and emit heartbeats. The
|
// Take the wake-up channel BEFORE reading, so an append landing
|
||||||
// context's deadline isn't propagated into XREAD's BLOCK arg —
|
// between the read and the wait still wakes us.
|
||||||
// that's a poll-style timeout on Redis itself — so we keep BLOCK
|
wake := store.Wait(stream)
|
||||||
// short.
|
entries, err := store.ReadAfter(ctx, stream, cursor, sseReadCount)
|
||||||
readCtx, cancel := context.WithTimeout(ctx, sseReadBlock+2*time.Second)
|
|
||||||
result, err := client.XRead(readCtx, &redis.XReadArgs{
|
|
||||||
Streams: []string{stream, cursor},
|
|
||||||
Block: sseReadBlock,
|
|
||||||
Count: sseReadCount,
|
|
||||||
}).Result()
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// redis.Nil = BLOCK timeout with no entries. Anything else
|
if ctx.Err() != nil {
|
||||||
// while the client is still connected is a real failure.
|
|
||||||
if errors.Is(err, redis.Nil) || isContextTimeout(err) {
|
|
||||||
// fall through to heartbeat check
|
|
||||||
} else if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
log.Printf("[SSE %s] XREAD error: %v", stream, err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("[SSE %s] read error: %v", stream, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, str := range result {
|
for _, e := range entries {
|
||||||
for _, msg := range str.Messages {
|
writeSSEEntry(w, flusher, e)
|
||||||
writeSSEEntry(w, flusher, msg)
|
cursor = e.ID
|
||||||
cursor = msg.ID
|
lastWrite = time.Now()
|
||||||
lastWrite = time.Now()
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
timer := time.NewTimer(sseReadBlock)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
case <-wake:
|
||||||
|
case <-timer.C:
|
||||||
}
|
}
|
||||||
|
timer.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heartbeat if quiet for >= the interval. Comment line; SSE
|
// Heartbeat if quiet for >= the interval. Comment line; SSE
|
||||||
@@ -149,87 +171,130 @@ func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// streamSnapshot sends one `set` event per existing record key, then returns
|
||||||
|
// the stream position the live tail continues from.
|
||||||
|
//
|
||||||
|
// This is how a consumer that rebuilds its state from events (meta-fuse sends
|
||||||
|
// Last-Event-ID 0-0 on every start) gets ALL of it. The stream itself only
|
||||||
|
// retains ~100k entries, so replaying it never could; meta-core used to
|
||||||
|
// republish every field into it on boot instead, which took minutes and still
|
||||||
|
// lost everything past the retention window.
|
||||||
|
//
|
||||||
|
// The head is captured BEFORE the snapshot is read, so a change racing the
|
||||||
|
// snapshot appears in the tail too — re-applying a `set` is idempotent. Only
|
||||||
|
// the final snapshot event carries an `id:`, so a consumer that disconnects
|
||||||
|
// mid-snapshot still holds its old cursor and gets a fresh snapshot.
|
||||||
|
func streamSnapshot(ctx context.Context, store *kv.Store, w http.ResponseWriter, flusher http.Flusher) (string, error) {
|
||||||
|
head, err := store.LastID(ctx, storage.MetaEventsStream)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||||
|
pending := ""
|
||||||
|
err = store.ScanPrefix(ctx, "file:", 5000, func(rows []kv.KV) error {
|
||||||
|
for _, row := range rows {
|
||||||
|
if pending != "" {
|
||||||
|
writeSnapshotEvent(w, pending, ts, "")
|
||||||
|
}
|
||||||
|
pending = row.K
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
return ctx.Err()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if pending != "" {
|
||||||
|
writeSnapshotEvent(w, pending, ts, head)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
return head, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSnapshotEvent(w http.ResponseWriter, key, ts, id string) {
|
||||||
|
payload, _ := json.Marshal(map[string]string{"key": key, "ts": ts})
|
||||||
|
if id != "" {
|
||||||
|
fmt.Fprintf(w, "id: %s\n", id)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "event: set\ndata: %s\n\n", payload)
|
||||||
|
}
|
||||||
|
|
||||||
// resolveStartCursor decides where to start reading.
|
// resolveStartCursor decides where to start reading.
|
||||||
// - No Last-Event-ID → start at "$" (only new entries).
|
// - No Last-Event-ID → the stream's last issued ID, resolved ONCE here (only
|
||||||
|
// entries appended from now on). Redis' "$" was re-resolved on every
|
||||||
|
// XREAD, which dropped anything appended between two reads.
|
||||||
// - Last-Event-ID provided AND still in stream → resume from it.
|
// - Last-Event-ID provided AND still in stream → resume from it.
|
||||||
// - Last-Event-ID provided BUT trimmed → emit one synthetic `gap` event
|
// - Last-Event-ID provided BUT trimmed → emit one synthetic `gap` event
|
||||||
// and resume from the oldest available entry's predecessor "0", so the
|
// and resume from the oldest available entry's predecessor "0", so the
|
||||||
// next XREAD picks up everything still in the stream.
|
// next read picks up everything still in the stream.
|
||||||
//
|
//
|
||||||
// The "still in stream" check is cheap: one XRANGE for the first entry.
|
// The "still in stream" check is cheap: one read of the first entry.
|
||||||
func resolveStartCursor(ctx context.Context, client *redis.Client, stream, lastID string, w http.ResponseWriter, flusher http.Flusher) string {
|
func resolveStartCursor(ctx context.Context, store *kv.Store, stream, lastID string, w http.ResponseWriter, flusher http.Flusher) string {
|
||||||
if lastID == "" {
|
|
||||||
return "$"
|
|
||||||
}
|
|
||||||
|
|
||||||
// One-entry XRANGE to find the oldest ID currently in the stream.
|
|
||||||
xCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
xCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
entries, err := client.XRangeN(xCtx, stream, "-", "+", 1).Result()
|
|
||||||
|
if lastID == "" {
|
||||||
|
head, err := store.LastID(xCtx, stream)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[SSE %s] head lookup failed: %v (starting from the beginning)", stream, err)
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
return head
|
||||||
|
}
|
||||||
|
|
||||||
|
oldestEntry, ok, err := store.Oldest(xCtx, stream)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Treat any error as "stream not available right now" and resume
|
// Treat any error as "stream not available right now" and resume
|
||||||
// from the cursor — XREAD will either find it or block until new
|
// from the cursor — the read either finds it or waits for new entries.
|
||||||
// entries arrive.
|
log.Printf("[SSE %s] oldest-entry probe failed: %v (resuming from cursor)", stream, err)
|
||||||
log.Printf("[SSE %s] XRANGE probe failed: %v (resuming from cursor)", stream, err)
|
|
||||||
return lastID
|
return lastID
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(entries) == 0 {
|
if !ok {
|
||||||
// Stream is empty. Cursor may be from a previous incarnation; safest
|
// Stream is empty. Cursor may be from a previous incarnation; safest
|
||||||
// is to resume from the cursor — XREAD against an empty stream
|
// is to resume from the cursor — reading an empty stream returns
|
||||||
// returns nothing and blocks until new entries arrive.
|
// nothing and waits until new entries arrive.
|
||||||
return lastID
|
return lastID
|
||||||
}
|
}
|
||||||
|
|
||||||
oldest := entries[0].ID
|
oldest := oldestEntry.ID
|
||||||
if compareStreamIDs(lastID, oldest) >= 0 {
|
if compareStreamIDs(lastID, oldest) >= 0 {
|
||||||
// Cursor is at or past the oldest — fine to resume.
|
// Cursor is at or past the oldest — fine to resume.
|
||||||
return lastID
|
return lastID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor was trimmed. Emit a gap event so consumers that care can react.
|
// Cursor was trimmed. Emit a gap event so consumers that care can react.
|
||||||
gap := map[string]string{
|
writeGap(w, flusher, lastID, oldest, "retention")
|
||||||
"requested": lastID,
|
|
||||||
"resumeFrom": oldest,
|
|
||||||
"reason": "retention",
|
|
||||||
}
|
|
||||||
payload, _ := json.Marshal(gap)
|
|
||||||
fmt.Fprintf(w, "event: gap\ndata: %s\n\n", string(payload))
|
|
||||||
flusher.Flush()
|
|
||||||
|
|
||||||
// Return "0" so the next XREAD picks up entries starting from the
|
// Return "0" so the next read picks up entries starting from the
|
||||||
// stream's true minimum — including `oldest` itself.
|
// stream's true minimum — including `oldest` itself.
|
||||||
return "0"
|
return "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeSSEEntry serialises one Redis Stream entry as an SSE event.
|
// writeSSEEntry serialises one stream entry as an SSE event.
|
||||||
//
|
//
|
||||||
// `event:` comes from the entry's `type` field (no prefix munging). `data:`
|
// `event:` comes from the entry's `type` field (no prefix munging). `data:`
|
||||||
// is a JSON object containing all OTHER fields. Field values from the Redis
|
// is a JSON object containing all OTHER fields.
|
||||||
// stream are interface{} (typically strings); we encode whatever the stream
|
func writeSSEEntry(w http.ResponseWriter, flusher http.Flusher, e kv.Entry) {
|
||||||
// holds.
|
|
||||||
func writeSSEEntry(w http.ResponseWriter, flusher http.Flusher, msg redis.XMessage) {
|
|
||||||
eventType := "message"
|
eventType := "message"
|
||||||
data := make(map[string]interface{}, len(msg.Values))
|
data := make(map[string]string, len(e.Values))
|
||||||
for k, v := range msg.Values {
|
for k, v := range e.Values {
|
||||||
if k == "type" {
|
if k == "type" {
|
||||||
if s, ok := v.(string); ok {
|
eventType = v
|
||||||
eventType = s
|
continue
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
data[k] = v
|
data[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, err := json.Marshal(data)
|
payload, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[SSE] failed to marshal entry %s: %v", msg.ID, err)
|
log.Printf("[SSE] failed to marshal entry %s: %v", e.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spec: each event ends with a blank line. id/event/data lines are
|
// Spec: each event ends with a blank line. id/event/data lines are
|
||||||
// terminated with single \n.
|
// terminated with single \n.
|
||||||
fmt.Fprintf(w, "id: %s\n", msg.ID)
|
fmt.Fprintf(w, "id: %s\n", e.ID)
|
||||||
fmt.Fprintf(w, "event: %s\n", eventType)
|
fmt.Fprintf(w, "event: %s\n", eventType)
|
||||||
fmt.Fprintf(w, "data: %s\n\n", string(payload))
|
fmt.Fprintf(w, "data: %s\n\n", string(payload))
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
@@ -264,17 +329,14 @@ func parseStreamID(id string) (int64, int64) {
|
|||||||
return ms, seq
|
return ms, seq
|
||||||
}
|
}
|
||||||
|
|
||||||
// isContextTimeout returns true for the context deadline error wrapped by
|
// writeGap emits the synthetic `gap` event: the consumer's cursor cannot be
|
||||||
// go-redis when XREAD's read context fires. We treat it the same as
|
// resumed losslessly and it should take a snapshot (Last-Event-ID: 0).
|
||||||
// redis.Nil — i.e. an empty poll cycle, not a fatal error.
|
func writeGap(w http.ResponseWriter, flusher http.Flusher, requested, resumeFrom, reason string) {
|
||||||
func isContextTimeout(err error) bool {
|
payload, _ := json.Marshal(map[string]string{
|
||||||
if err == nil {
|
"requested": requested,
|
||||||
return false
|
"resumeFrom": resumeFrom,
|
||||||
}
|
"reason": reason,
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
})
|
||||||
return true
|
fmt.Fprintf(w, "event: gap\ndata: %s\n\n", string(payload))
|
||||||
}
|
flusher.Flush()
|
||||||
// go-redis sometimes wraps this in its own error type. String match is
|
|
||||||
// a safety net.
|
|
||||||
return strings.Contains(err.Error(), "context deadline exceeded")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// syncRecorder is a ResponseRecorder safe to read while the handler writes.
|
||||||
|
type syncRecorder struct {
|
||||||
|
*httptest.ResponseRecorder
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *syncRecorder) Write(b []byte) (int, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.ResponseRecorder.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *syncRecorder) body() string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-Event-ID 0-0 on meta:events returns the whole current state — one set
|
||||||
|
// per record key, only the last carrying an id — then the live tail.
|
||||||
|
func TestMetaEventsSnapshotThenTail(t *testing.T) {
|
||||||
|
stor := newTestStorage(t)
|
||||||
|
s := &Server{storage: stor}
|
||||||
|
a := testSigner(t, 1)
|
||||||
|
for i, cid := range []string{"bafyR1", "bafyR2"} {
|
||||||
|
storeClaims(t, stor, signed(t, a, cid, "title", "T", 1), signed(t, a, cid, "n", string(rune('a'+i)), 1))
|
||||||
|
}
|
||||||
|
r1, r2 := stor.ResolveRoot("bafyR1"), stor.ResolveRoot("bafyR2")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/events/meta", nil).WithContext(ctx)
|
||||||
|
req.Header.Set("Last-Event-ID", "0-0")
|
||||||
|
rec := &syncRecorder{ResponseRecorder: httptest.NewRecorder()}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { s.handleEventsMeta(rec, req); close(done) }()
|
||||||
|
|
||||||
|
waitFor := func(substr string) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for !strings.Contains(rec.body(), substr) {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("timed out waiting for %q in:\n%s", substr, rec.body())
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitFor(`"key":"file:` + r2 + `/title"`)
|
||||||
|
storeClaims(t, stor, signed(t, a, "bafyR1", "title", "changed", 2))
|
||||||
|
waitFor(`event: set`)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
body := rec.body()
|
||||||
|
keys := []string{"file:" + r1 + "/title", "file:" + r1 + "/n", "file:" + r2 + "/title", "file:" + r2 + "/n",
|
||||||
|
"file:" + r1 + "/cids/bafyR1", "file:" + r2 + "/cids/bafyR2"}
|
||||||
|
for _, k := range keys {
|
||||||
|
if !strings.Contains(body, `"key":"`+k+`"`) {
|
||||||
|
t.Errorf("snapshot is missing %s", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events := strings.Split(strings.TrimSpace(body), "\n\n")
|
||||||
|
if len(events) != 7 {
|
||||||
|
t.Fatalf("want 6 snapshot events + 1 live event, got %d:\n%s", len(events), body)
|
||||||
|
}
|
||||||
|
for i, e := range events[:5] {
|
||||||
|
if strings.Contains(e, "id: ") {
|
||||||
|
t.Errorf("snapshot event %d carries an id: %q", i, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(events[5], "id: ") || !strings.HasPrefix(events[6], "id: ") {
|
||||||
|
t.Fatalf("the last snapshot event and the live event must carry ids:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(events[6], `"key":"file:`+r1+`/title"`) {
|
||||||
|
t.Fatalf("live tail did not follow the snapshot: %q", events[6])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cursor older than the last cache rebuild gets `gap` (reason rebuild): the
|
||||||
|
// rebuild published no per-key events, so the consumer must re-snapshot.
|
||||||
|
func TestMetaEventsGapAfterRebuild(t *testing.T) {
|
||||||
|
stor := newTestStorage(t)
|
||||||
|
s := &Server{storage: stor}
|
||||||
|
storeClaims(t, stor, signed(t, testSigner(t, 1), "bafyA", "title", "T", 1))
|
||||||
|
if err := stor.Rebuild(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/events/meta", nil).WithContext(ctx)
|
||||||
|
req.Header.Set("Last-Event-ID", "1000-0")
|
||||||
|
rec := &syncRecorder{ResponseRecorder: httptest.NewRecorder()}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { s.handleEventsMeta(rec, req); close(done) }()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for !strings.Contains(rec.body(), "event: gap") {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("no gap after rebuild:\n%s", rec.body())
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
if !strings.Contains(rec.body(), `"reason":"rebuild"`) {
|
||||||
|
t.Fatalf("gap reason: %s", rec.body())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,8 +27,10 @@ type fileStatsJSON struct {
|
|||||||
// dashboard shows, in one request.
|
// dashboard shows, in one request.
|
||||||
//
|
//
|
||||||
// Deliberately split by cost. Records/RedisKeys/RedisMemory/Identities are
|
// Deliberately split by cost. Records/RedisKeys/RedisMemory/Identities are
|
||||||
// O(1) commands (SCARD, DBSIZE, INFO) plus a directory listing, recomputed on
|
// cheap (an index range count, a cached row count, a file stat) plus a
|
||||||
// every call. Files and UDLUsers need a keyspace walk — ~0.5s for the file
|
// directory listing, recomputed on every call. The `redis*` JSON names
|
||||||
|
// predate the move to SQLite and are kept for dashboard compatibility:
|
||||||
|
// redisKeys is the number of claim rows (truth), redisMemory both databases' on-disk size. Files and UDLUsers need a keyspace walk — ~0.5s for the file
|
||||||
// tuples and ~3s for the UDL SCAN on an 88k-record box — so they are served
|
// tuples and ~3s for the UDL SCAN on an 88k-record box — so they are served
|
||||||
// from a background-refreshed cache and are null until the first sweep lands.
|
// from a background-refreshed cache and are null until the first sweep lands.
|
||||||
type StatsResponse struct {
|
type StatsResponse struct {
|
||||||
@@ -47,7 +49,7 @@ type StatsResponse struct {
|
|||||||
|
|
||||||
// statsCache holds the expensive half of /api/stats.
|
// statsCache holds the expensive half of /api/stats.
|
||||||
//
|
//
|
||||||
// Why a cache and not just "compute it": the dashboard polls. A 3s Redis walk
|
// Why a cache and not just "compute it": the dashboard polls. A multi-second store walk
|
||||||
// per tick, per open tab, is a self-inflicted outage on a box that is also
|
// per tick, per open tab, is a self-inflicted outage on a box that is also
|
||||||
// serving playback — the same shape as meta-sort's /api/stats full sweep. So
|
// serving playback — the same shape as meta-sort's /api/stats full sweep. So
|
||||||
// the handler NEVER waits on a walk: it returns whatever the last sweep
|
// the handler NEVER waits on a walk: it returns whatever the last sweep
|
||||||
@@ -86,7 +88,7 @@ func (c *statsCache) refreshIfStale(sweep func() (*fileStatsJSON, *int)) bool {
|
|||||||
c.running = false
|
c.running = false
|
||||||
// A failed sweep leaves the previous numbers in place rather than
|
// A failed sweep leaves the previous numbers in place rather than
|
||||||
// blanking the tiles; sweptAt still advances so a hard-failing walk
|
// blanking the tiles; sweptAt still advances so a hard-failing walk
|
||||||
// (Redis down) does not spin once per poll.
|
// (store down) does not spin once per poll.
|
||||||
c.sweptAt = time.Now()
|
c.sweptAt = time.Now()
|
||||||
if files != nil {
|
if files != nil {
|
||||||
c.files = files
|
c.files = files
|
||||||
@@ -113,7 +115,7 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
resp.Records = n
|
resp.Records = n
|
||||||
}
|
}
|
||||||
if n, err := s.storage.CountKeys(); err == nil {
|
if n, err := s.storage.CountClaims(); err == nil {
|
||||||
resp.RedisKeys = n
|
resp.RedisKeys = n
|
||||||
}
|
}
|
||||||
if mem, err := s.storage.GetMemoryInfo(); err == nil {
|
if mem, err := s.storage.GetMemoryInfo(); err == nil {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/claim"
|
||||||
|
"github.com/metazla/meta-core/internal/identity"
|
||||||
|
"github.com/metazla/meta-core/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestStorage opens (and starts) a fresh store in the test's temp dir.
|
||||||
|
func newTestStorage(t *testing.T) *storage.Client {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
c := storage.NewClient()
|
||||||
|
if err := c.Connect(filepath.Join(dir, "meta-core.db"), filepath.Join(dir, "cache.db")); err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.EnsureSchemaVersion(storage.SchemaVersion); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Start(); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = c.Close() })
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSigner(t *testing.T, n byte) *identity.Identity {
|
||||||
|
t.Helper()
|
||||||
|
id, err := identity.Import(fmt.Sprintf("%02x%062x", n, 1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// signed builds a claim signed by id; v is string, []byte or nil.
|
||||||
|
func signed(t *testing.T, id *identity.Identity, cid, key string, v any, ts uint64) claim.Claim {
|
||||||
|
t.Helper()
|
||||||
|
cl := claim.Claim{CID: cid, UID: id.UID, Key: key, TS: ts}
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
cl.Value = claim.Text(x)
|
||||||
|
case []byte:
|
||||||
|
cl.Value = claim.Bytes(x)
|
||||||
|
}
|
||||||
|
if err := cl.Sign(id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return cl
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeClaims(t *testing.T, c *storage.Client, claims ...claim.Claim) {
|
||||||
|
t.Helper()
|
||||||
|
res, err := c.WriteClaims(context.Background(), claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, r := range res {
|
||||||
|
if r.Status != storage.StatusAccepted {
|
||||||
|
t.Fatalf("claim %d: %s %s", i, r.Status, r.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
-206
@@ -1,13 +1,14 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
// User Data Layer (UDL) HTTP surface.
|
// User Data Layer facade (/api/udl/*). Since meta-core v2.0.0 a user's data are
|
||||||
|
// ordinary MMUR2 claims signed by the user's key (plaintext for public-social
|
||||||
|
// keys, ciphertext for private ones); these routes keep their paths for the
|
||||||
|
// clients (meta-watch, meta-read, meta-listen, the gateway mirror) and speak
|
||||||
|
// claims in the §3.4 JSON form:
|
||||||
//
|
//
|
||||||
// Backs meta-watch's per-user store (My List, Continue Watching, ratings,
|
// {"cid","uid","key","value": "…" | {"b64": "…"} | null,"ts","sig"}
|
||||||
// profile) now that the authoritative store lives in meta-core instead of
|
//
|
||||||
// meta-watch's local op-log. Records arrive already signed (and, for
|
// A write is the same as POST /api/claims; newest ts wins per (uid, cid, key).
|
||||||
// private-tier keys, already encrypted) as opaque base64 CBOR blobs; meta-core
|
|
||||||
// version-gates and indexes them but never inspects the crypto. See
|
|
||||||
// internal/storage/udl.go for the Redis model.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -17,82 +18,46 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/claim"
|
||||||
"github.com/metazla/meta-core/internal/storage"
|
"github.com/metazla/meta-core/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UDLPutRequest is the body of PUT /api/udl/record.
|
// UDLPutResponse answers PUT /api/udl/record.
|
||||||
type UDLPutRequest struct {
|
|
||||||
Uid string `json:"uid"`
|
|
||||||
Cid string `json:"cid"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
Version int64 `json:"version"`
|
|
||||||
Ts int64 `json:"ts"`
|
|
||||||
Record string `json:"record"` // base64 CBOR, opaque + signed
|
|
||||||
Value *json.RawMessage `json:"value,omitempty"`
|
|
||||||
// Tombstone marks this write as a deletion of (uid, cid, key). The record
|
|
||||||
// itself is opaque here — meta-core never decodes the CBOR, and a private-
|
|
||||||
// tier value and a tombstone are equally "no plaintext value" — so the
|
|
||||||
// writer has to say. Without it the cid is never removed from
|
|
||||||
// udl:idx:user:<uid>:key:<key>, which then grows for the life of the
|
|
||||||
// account and is returned in full on every My List / Continue Watching
|
|
||||||
// read. Omitted by older writers, which keeps the previous behaviour.
|
|
||||||
Tombstone bool `json:"tombstone,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UDLPutResponse is the response of PUT /api/udl/record.
|
|
||||||
type UDLPutResponse struct {
|
type UDLPutResponse struct {
|
||||||
Accepted bool `json:"accepted"`
|
Accepted bool `json:"accepted"`
|
||||||
|
Status storage.ClaimStatus `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDLPutBatchRequest is the body of PUT /api/udl/records — many cells, one
|
// UDLPutBatchRequest is PUT /api/udl/records.
|
||||||
// request. Each entry is exactly a UDLPutRequest, so a client can build the
|
|
||||||
// same signed record for either endpoint.
|
|
||||||
type UDLPutBatchRequest struct {
|
type UDLPutBatchRequest struct {
|
||||||
Records []UDLPutRequest `json:"records"`
|
Records []claim.Claim `json:"records"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDLPutBatchResult is one cell's outcome. Accepted=false with an empty Error
|
// UDLPutBatchResult is one entry of the batch response.
|
||||||
// means the write was stale (the single-record endpoint's 409), which in a
|
|
||||||
// batch is an ordinary outcome rather than a failure.
|
|
||||||
type UDLPutBatchResult struct {
|
type UDLPutBatchResult struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Accepted bool `json:"accepted"`
|
Accepted bool `json:"accepted"`
|
||||||
Error string `json:"error,omitempty"`
|
Status storage.ClaimStatus `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDLPutBatchResponse mirrors the request order index-for-index.
|
// UDLPutBatchResponse answers PUT /api/udl/records.
|
||||||
type UDLPutBatchResponse struct {
|
type UDLPutBatchResponse struct {
|
||||||
Results []UDLPutBatchResult `json:"results"`
|
Results []UDLPutBatchResult `json:"results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxUDLBatch bounds one batch. Each record is a Redis round trip while the
|
|
||||||
// request is held open; sized to match the sign batch on the other side of the
|
|
||||||
// same operation (see maxSignBatch).
|
|
||||||
const maxUDLBatch = 1000
|
const maxUDLBatch = 1000
|
||||||
|
|
||||||
// UDLRecordResponse is the response of GET /api/udl/record.
|
// UDLRecordResponse answers GET /api/udl/record. Claim is the stored claim,
|
||||||
|
// tombstone included (its ts is what a newer write must exceed); Exists is
|
||||||
|
// false when it is absent or a tombstone.
|
||||||
type UDLRecordResponse struct {
|
type UDLRecordResponse struct {
|
||||||
Record string `json:"record"`
|
Exists bool `json:"exists"`
|
||||||
Version int64 `json:"version"`
|
Claim *claim.Claim `json:"claim"`
|
||||||
Ts int64 `json:"ts"`
|
|
||||||
Exists bool `json:"exists"`
|
|
||||||
// Value is the plaintext JSON scalar stored beside the opaque record, and
|
|
||||||
// is present only for PUBLIC-tier keys (like, rating, profile:name,
|
|
||||||
// profile:avatar). json.RawMessage rather than string because the field is
|
|
||||||
// generic over keys — a bool for `like`, a number for `rating`, a string
|
|
||||||
// for a profile name (see aggregatePublicValues) — and because it is the
|
|
||||||
// exact type UDLPutRequest.Value uses, so a value written through PUT reads
|
|
||||||
// back byte-identical.
|
|
||||||
//
|
|
||||||
// Absent, never null, when there is no plaintext: private-tier cells,
|
|
||||||
// tombstones and never-written cells must not read as an empty public
|
|
||||||
// value. Readers key off presence, not `exists`.
|
|
||||||
Value *json.RawMessage `json:"value,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDLAggregate mirrors meta-watch's Aggregate wire shape exactly (snake_case
|
// UDLAggregate is one key's public aggregate on a cid.
|
||||||
// field names; `avg` is null for boolean keys like `like`), so the meta-watch
|
|
||||||
// client deserializes it into the same struct it serializes to the UI.
|
|
||||||
type UDLAggregate struct {
|
type UDLAggregate struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
@@ -101,10 +66,17 @@ type UDLAggregate struct {
|
|||||||
SampleSize int `json:"sample_size"`
|
SampleSize int `json:"sample_size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUDLRecordGet handles GET /api/udl/record?uid&cid&key
|
func (s *Server) udlReady(w http.ResponseWriter) bool {
|
||||||
func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.storage.IsConnected() {
|
if !s.storage.IsConnected() {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUDLRecordGet handles GET /api/udl/record?uid=&cid=&key=.
|
||||||
|
func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.udlReady(w) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
@@ -113,85 +85,53 @@ func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "uid, cid and key are required")
|
writeError(w, http.StatusBadRequest, "uid, cid and key are required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cl, ok, err := s.storage.UDLGetClaim(uid, cid, key)
|
||||||
cell, exists, err := s.storage.UDLGetCell(uid, cid, key)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp := UDLRecordResponse{
|
resp := UDLRecordResponse{}
|
||||||
Record: cell.Record,
|
if ok {
|
||||||
Version: cell.Version,
|
resp.Claim = &cl
|
||||||
Ts: cell.Ts,
|
resp.Exists = !cl.Value.IsNull()
|
||||||
Exists: exists,
|
|
||||||
}
|
|
||||||
// json.Valid guards the encoder: a malformed `value` hash field (hand-edited
|
|
||||||
// Redis, a future writer bug) would otherwise fail mid-encode and emit a
|
|
||||||
// truncated body instead of a readable error.
|
|
||||||
if exists && cell.HasValue && json.Valid([]byte(cell.Value)) {
|
|
||||||
raw := json.RawMessage(cell.Value)
|
|
||||||
resp.Value = &raw
|
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUDLRecordPut handles PUT /api/udl/record. Version-gated: a stale write
|
// handleUDLRecordPut handles PUT /api/udl/record with one claim as the body.
|
||||||
// (version <= stored) returns 409 stale_version so the client can re-read and
|
|
||||||
// retry with a higher version.
|
|
||||||
func (s *Server) handleUDLRecordPut(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUDLRecordPut(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.udlReady(w) {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req UDLPutRequest
|
var cl claim.Claim
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&cl); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
writeError(w, http.StatusBadRequest, "body must be one claim: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Uid == "" || req.Cid == "" || req.Key == "" || req.Record == "" {
|
results, err := s.storage.WriteClaims(r.Context(), []claim.Claim{cl})
|
||||||
writeError(w, http.StatusBadRequest, "uid, cid, key and record are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
publicValue := ""
|
|
||||||
hasValue := false
|
|
||||||
if req.Value != nil {
|
|
||||||
publicValue = string(*req.Value)
|
|
||||||
hasValue = true
|
|
||||||
}
|
|
||||||
|
|
||||||
accepted, err := s.storage.UDLUpsertIfNewer(req.Uid, req.Cid, req.Key, req.Version, req.Ts, req.Record, publicValue, hasValue, req.Tombstone)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !accepted {
|
res := results[0]
|
||||||
writeErrorSlug(w, http.StatusConflict, "stale_version", "a record with an equal or newer version already exists", true)
|
switch res.Status {
|
||||||
return
|
case storage.StatusAccepted, storage.StatusUnchanged:
|
||||||
|
writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true, Status: res.Status})
|
||||||
|
case storage.StatusStale:
|
||||||
|
writeErrorSlug(w, http.StatusConflict, "stale_version", "a claim with an equal or newer ts already exists", true)
|
||||||
|
default:
|
||||||
|
writeErrorSlug(w, http.StatusBadRequest, string(res.Status), res.Reason, false)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUDLRecordsPut handles PUT /api/udl/records — the batch form of
|
// handleUDLRecordsPut handles PUT /api/udl/records {records: [claim, …]}.
|
||||||
// handleUDLRecordPut.
|
|
||||||
//
|
|
||||||
// Exists for the bulk verbs above this layer ("mark this whole series seen"),
|
|
||||||
// where one user action produces one cell per episode. Each cell still goes
|
|
||||||
// through the same version-gated Lua upsert, so the CRDT semantics are
|
|
||||||
// identical — this batches the transport only.
|
|
||||||
//
|
|
||||||
// **Not transactional, and deliberately not 409.** A per-record `accepted` flag
|
|
||||||
// replaces the single-record conflict status: in a batch, one stale cell is a
|
|
||||||
// normal outcome (another device already marked that episode) and must not fail
|
|
||||||
// the 299 cells beside it. Callers read the array, not the status code.
|
|
||||||
func (s *Server) handleUDLRecordsPut(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUDLRecordsPut(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.udlReady(w) {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req UDLPutBatchRequest
|
var req UDLPutBatchRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Records) == 0 {
|
if len(req.Records) == 0 {
|
||||||
@@ -202,91 +142,66 @@ func (s *Server) handleUDLRecordsPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "too many records in one batch")
|
writeError(w, http.StatusBadRequest, "too many records in one batch")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
results := make([]UDLPutBatchResult, 0, len(req.Records))
|
results, err := s.storage.WriteClaims(r.Context(), req.Records)
|
||||||
for _, rec := range req.Records {
|
if err != nil {
|
||||||
if rec.Uid == "" || rec.Cid == "" || rec.Key == "" || rec.Record == "" {
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
results = append(results, UDLPutBatchResult{
|
return
|
||||||
Key: rec.Key,
|
|
||||||
Error: "uid, cid, key and record are required",
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
publicValue := ""
|
|
||||||
hasValue := false
|
|
||||||
if rec.Value != nil {
|
|
||||||
publicValue = string(*rec.Value)
|
|
||||||
hasValue = true
|
|
||||||
}
|
|
||||||
accepted, err := s.storage.UDLUpsertIfNewer(rec.Uid, rec.Cid, rec.Key, rec.Version, rec.Ts, rec.Record, publicValue, hasValue, rec.Tombstone)
|
|
||||||
if err != nil {
|
|
||||||
// A storage error is per-record here rather than fatal for the whole
|
|
||||||
// batch: the cells already written stay written, and reporting which
|
|
||||||
// ones failed lets the caller retry exactly those.
|
|
||||||
results = append(results, UDLPutBatchResult{Key: rec.Key, Error: err.Error()})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results = append(results, UDLPutBatchResult{Key: rec.Key, Accepted: accepted})
|
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, UDLPutBatchResponse{Results: results})
|
out := make([]UDLPutBatchResult, len(results))
|
||||||
|
for i, res := range results {
|
||||||
|
out[i] = UDLPutBatchResult{
|
||||||
|
Key: req.Records[i].Key,
|
||||||
|
Status: res.Status,
|
||||||
|
Accepted: res.Status == storage.StatusAccepted || res.Status == storage.StatusUnchanged,
|
||||||
|
Error: res.Reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, UDLPutBatchResponse{Results: out})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key}
|
func (s *Server) writeUDLEntries(w http.ResponseWriter, uid, cid, key string) {
|
||||||
// (active_for_user_key — My List, Continue Watching).
|
entries, err := s.storage.UDLClaimsForUser(uid, cid, key)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []claim.Claim{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key}: every live claim
|
||||||
|
// of uid on key (My List, Continue Watching).
|
||||||
func (s *Server) handleUDLUserKey(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUDLUserKey(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.udlReady(w) {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
uid, key := vars["uid"], vars["key"]
|
if vars["uid"] == "" || vars["key"] == "" {
|
||||||
if uid == "" || key == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "uid and key are required")
|
writeError(w, http.StatusBadRequest, "uid and key are required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
entries, err := s.storage.UDLListUserKey(uid, key)
|
s.writeUDLEntries(w, vars["uid"], "", vars["key"])
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUDLUserCid handles GET /api/udl/user/{uid}/cid/{cid}
|
// handleUDLUserCid handles GET /api/udl/user/{uid}/cid/{cid}.
|
||||||
// (list_for_user_cid).
|
|
||||||
func (s *Server) handleUDLUserCid(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUDLUserCid(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.udlReady(w) {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
uid, cid := vars["uid"], vars["cid"]
|
if vars["uid"] == "" || vars["cid"] == "" {
|
||||||
if uid == "" || cid == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "uid and cid are required")
|
writeError(w, http.StatusBadRequest, "uid and cid are required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
entries, err := s.storage.UDLListUserCid(uid, cid)
|
s.writeUDLEntries(w, vars["uid"], vars["cid"], "")
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUDLUserAll handles GET /api/udl/user/{uid}/all?after=<cid>&limit=<n>
|
// handleUDLUserAll handles GET /api/udl/user/{uid}/all?after=&limit= — the
|
||||||
// — every record a uid owns, for cross-instance profile recovery.
|
// whole profile, paged on cid boundaries (recovery / export).
|
||||||
//
|
|
||||||
// The other user-scoped reads answer a question with one coordinate already
|
|
||||||
// known. Recovery knows only the uid: a box that has just imported a secret key
|
|
||||||
// has no idea which cids or keys the profile ever touched, so it needs the
|
|
||||||
// whole set or it gets nothing.
|
|
||||||
//
|
|
||||||
// Paged on a cid boundary — `nextCid` empty means the export is complete. The
|
|
||||||
// response is opaque signed records, exactly as stored; whoever consumes it is
|
|
||||||
// responsible for verifying each one before trusting it (meta-core has never
|
|
||||||
// held the account key and cannot verify on anyone's behalf).
|
|
||||||
func (s *Server) handleUDLUserAll(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUDLUserAll(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.udlReady(w) {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid := mux.Vars(r)["uid"]
|
uid := mux.Vars(r)["uid"]
|
||||||
@@ -301,31 +216,22 @@ func (s *Server) handleUDLUserAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "limit must be a positive integer")
|
writeError(w, http.StatusBadRequest, "limit must be a positive integer")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n < limit {
|
limit = min(n, limit)
|
||||||
limit = n
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
entries, nextCid, err := s.storage.UDLAllForUser(uid, r.URL.Query().Get("after"), limit)
|
entries, nextCid, err := s.storage.UDLAllForUser(uid, r.URL.Query().Get("after"), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
if entries == nil {
|
||||||
"entries": entries,
|
entries = []claim.Claim{}
|
||||||
"nextCid": nextCid,
|
}
|
||||||
})
|
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries, "nextCid": nextCid})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUDLUserCount handles GET /api/udl/user/{uid}/count — how many cids a
|
// handleUDLUserCount handles GET /api/udl/user/{uid}/count.
|
||||||
// uid holds records for.
|
|
||||||
//
|
|
||||||
// Exists so a gateway mirror can enforce a per-profile quota without walking
|
|
||||||
// anything: 32 random bytes are a valid uid, so an unbounded mirror is a
|
|
||||||
// disk-fill DoS that needs no account. A quota check that itself cost a scan
|
|
||||||
// would just move the denial of service one layer up.
|
|
||||||
func (s *Server) handleUDLUserCount(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUDLUserCount(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.udlReady(w) {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid := mux.Vars(r)["uid"]
|
uid := mux.Vars(r)["uid"]
|
||||||
@@ -341,10 +247,10 @@ func (s *Server) handleUDLUserCount(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"cids": n})
|
writeJSON(w, http.StatusOK, map[string]interface{}{"cids": n})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUDLCidUsers handles GET /api/udl/cid/{cid}/users (uids_for_cid).
|
// handleUDLCidUsers handles GET /api/udl/cid/{cid}/users: signers holding live
|
||||||
|
// claims on the cid.
|
||||||
func (s *Server) handleUDLCidUsers(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUDLCidUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.udlReady(w) {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cid := mux.Vars(r)["cid"]
|
cid := mux.Vars(r)["cid"]
|
||||||
@@ -360,12 +266,9 @@ func (s *Server) handleUDLCidUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"users": users})
|
writeJSON(w, http.StatusOK, map[string]interface{}{"users": users})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUDLAggregate handles GET /api/udl/cid/{cid}/aggregate?keys=like,rating.
|
// handleUDLAggregate handles GET /api/udl/cid/{cid}/aggregate?keys=like,userRating.
|
||||||
// Computes per-key population stats from the stored public value fields
|
|
||||||
// (absorbs meta-watch's aggregate.rs). Reflects only records this core holds.
|
|
||||||
func (s *Server) handleUDLAggregate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUDLAggregate(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.storage.IsConnected() {
|
if !s.udlReady(w) {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cid := mux.Vars(r)["cid"]
|
cid := mux.Vars(r)["cid"]
|
||||||
@@ -375,7 +278,7 @@ func (s *Server) handleUDLAggregate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
keysParam := r.URL.Query().Get("keys")
|
keysParam := r.URL.Query().Get("keys")
|
||||||
if keysParam == "" {
|
if keysParam == "" {
|
||||||
keysParam = "like,rating"
|
keysParam = "like,userRating"
|
||||||
}
|
}
|
||||||
aggregates := make([]UDLAggregate, 0)
|
aggregates := make([]UDLAggregate, 0)
|
||||||
for _, key := range strings.Split(keysParam, ",") {
|
for _, key := range strings.Split(keysParam, ",") {
|
||||||
|
|||||||
+74
-188
@@ -7,220 +7,106 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/alicebob/miniredis/v2"
|
"github.com/metazla/meta-core/internal/claim"
|
||||||
"github.com/metazla/meta-core/internal/storage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// newUDLTestServer returns a Server wired to an in-memory Redis.
|
|
||||||
//
|
|
||||||
// The struct is built directly rather than through NewServer: NewServer boots
|
|
||||||
// the file watcher, mounts manager, watchers poller and WebDAV handler, none of
|
|
||||||
// which the UDL handlers touch — they use s.storage and nothing else.
|
|
||||||
func newUDLTestServer(t *testing.T) *Server {
|
func newUDLTestServer(t *testing.T) *Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
return &Server{storage: newTestStorage(t)}
|
||||||
mr := miniredis.RunT(t)
|
|
||||||
c := storage.NewClient("")
|
|
||||||
if err := c.Connect("redis://" + mr.Addr()); err != nil {
|
|
||||||
t.Fatalf("connect to miniredis: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = c.Close() })
|
|
||||||
return &Server{storage: c}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRecord drives handleUDLRecordGet and decodes the body into raw JSON
|
func udlGet(t *testing.T, s *Server, uid, cid, key string) UDLRecordResponse {
|
||||||
// fields, so a test can assert on the *encoding* of `value` (is it a quoted
|
|
||||||
// string? is the key there at all?) rather than on what a typed struct would
|
|
||||||
// have coerced it into.
|
|
||||||
func getRecord(t *testing.T, s *Server, uid, cid, key string) (int, map[string]json.RawMessage) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/udl/record?uid="+uid+"&cid="+cid+"&key="+key, nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/udl/record?uid="+uid+"&cid="+cid+"&key="+key, nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
s.handleUDLRecordGet(rr, req)
|
s.handleUDLRecordGet(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
var body map[string]json.RawMessage
|
t.Fatalf("GET record = %d %s", rr.Code, rr.Body)
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
|
||||||
t.Fatalf("decode response %q: %v", rr.Body.String(), err)
|
|
||||||
}
|
}
|
||||||
return rr.Code, body
|
var resp UDLRecordResponse
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// The plaintext value of a PUBLIC-tier cell must come back over the wire, and
|
func udlPutBatch(t *testing.T, s *Server, claims ...claim.Claim) UDLPutBatchResponse {
|
||||||
// come back *as it was written* — a JSON string stays a quoted string, a bool
|
|
||||||
// stays a bool.
|
|
||||||
//
|
|
||||||
// Both halves matter. Without the value at all, the meta-core dashboard can
|
|
||||||
// only ever show 44-character base58 uids: display names live in the User Data
|
|
||||||
// Layer as the public cell (uid, "self", "profile:name"), and the plaintext
|
|
||||||
// sits in Redis where the storage layer has always read it — the handler just
|
|
||||||
// used to drop it. And the type has to stay json.RawMessage: this endpoint is
|
|
||||||
// generic over keys, so a `*string` would mangle `like` (bool) and `rating`
|
|
||||||
// (number) while looking correct for names.
|
|
||||||
func TestUDLRecordGet_ReturnsPublicValue(t *testing.T) {
|
|
||||||
s := newUDLTestServer(t)
|
|
||||||
|
|
||||||
const uid = "zAlice"
|
|
||||||
if ok, err := s.storage.UDLUpsertIfNewer(uid, "self", "profile:name", 1, 100, "cmVj", `"Ada Lovelace"`, true, false); err != nil || !ok {
|
|
||||||
t.Fatalf("seed name: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
if ok, err := s.storage.UDLUpsertIfNewer(uid, "bagcsaaa", "like", 1, 100, "cmVj", `true`, true, false); err != nil || !ok {
|
|
||||||
t.Fatalf("seed like: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
code, body := getRecord(t, s, uid, "self", "profile:name")
|
|
||||||
if code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", code)
|
|
||||||
}
|
|
||||||
if got := string(body["value"]); got != `"Ada Lovelace"` {
|
|
||||||
t.Fatalf("value = %s, want a quoted JSON string", got)
|
|
||||||
}
|
|
||||||
var name string
|
|
||||||
if err := json.Unmarshal(body["value"], &name); err != nil || name != "Ada Lovelace" {
|
|
||||||
t.Fatalf("value should unmarshal to the plain name, got %q err=%v", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A boolean key survives unquoted — the assertion a *string field fails.
|
|
||||||
if _, body = getRecord(t, s, uid, "bagcsaaa", "like"); string(body["value"]) != "true" {
|
|
||||||
t.Fatalf("like value = %s, want the bare JSON literal true", body["value"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A cell with no plaintext must omit `value` entirely rather than send null.
|
|
||||||
//
|
|
||||||
// That is the shape of every private-tier key (seek, watched, card, localpath —
|
|
||||||
// their real value rides encrypted inside the opaque record) and of every
|
|
||||||
// tombstone. A reader keys off presence: "there is no plaintext here" and "the
|
|
||||||
// plaintext is null" are different facts, and a name cleared by its owner
|
|
||||||
// leaves the cell existing with no value, which must not read as a name.
|
|
||||||
func TestUDLRecordGet_OmitsValueForPrivateCell(t *testing.T) {
|
|
||||||
s := newUDLTestServer(t)
|
|
||||||
|
|
||||||
const uid = "zBob"
|
|
||||||
if ok, err := s.storage.UDLUpsertIfNewer(uid, "bagcsaaa", "seek", 1, 100, "ZW5jcnlwdGVk", "", false, false); err != nil || !ok {
|
|
||||||
t.Fatalf("seed private cell: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
code, body := getRecord(t, s, uid, "bagcsaaa", "seek")
|
|
||||||
if code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", code)
|
|
||||||
}
|
|
||||||
if _, ok := body["value"]; ok {
|
|
||||||
t.Fatalf("private cell must omit `value`, got %s", body["value"])
|
|
||||||
}
|
|
||||||
if string(body["exists"]) != "true" {
|
|
||||||
t.Fatalf("exists = %s, want true", body["exists"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A cell that was never written answers 200 with exists:false and no value —
|
|
||||||
// not a 404, and not an empty-string value that would render as a blank name.
|
|
||||||
func TestUDLRecordGet_MissingCellHasNoValue(t *testing.T) {
|
|
||||||
s := newUDLTestServer(t)
|
|
||||||
|
|
||||||
code, body := getRecord(t, s, "zNobody", "self", "profile:name")
|
|
||||||
if code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", code)
|
|
||||||
}
|
|
||||||
if string(body["exists"]) != "false" {
|
|
||||||
t.Fatalf("exists = %s, want false", body["exists"])
|
|
||||||
}
|
|
||||||
if _, ok := body["value"]; ok {
|
|
||||||
t.Fatalf("missing cell must omit `value`, got %s", body["value"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// putRecords drives handleUDLRecordsPut with a raw JSON body and decodes the
|
|
||||||
// per-record results.
|
|
||||||
func putRecords(t *testing.T, s *Server, body string) (int, UDLPutBatchResponse) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(UDLPutBatchRequest{Records: claims})
|
||||||
req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(body))
|
req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(string(body)))
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
s.handleUDLRecordsPut(rr, req)
|
s.handleUDLRecordsPut(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT records = %d %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
var resp UDLPutBatchResponse
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
var out UDLPutBatchResponse
|
// The facade stores and returns MMUR2 claims: public values in clear,
|
||||||
if rr.Code == http.StatusOK {
|
// private ones as ciphertext, tombstones with their ts.
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
func TestUDLFacadeRoundTrip(t *testing.T) {
|
||||||
t.Fatalf("decode response %q: %v", rr.Body.String(), err)
|
s := newUDLTestServer(t)
|
||||||
|
me := testSigner(t, 7)
|
||||||
|
res := udlPutBatch(t, s,
|
||||||
|
signed(t, me, "self", "profile:name", "Ada Lovelace", 100),
|
||||||
|
signed(t, me, "bagcsaaa", "like", "true", 100),
|
||||||
|
signed(t, me, "bagcsaaa", "seek", []byte("ciphertext"), 100),
|
||||||
|
)
|
||||||
|
for _, r := range res.Results {
|
||||||
|
if !r.Accepted {
|
||||||
|
t.Fatalf("not accepted: %+v", r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rr.Code, out
|
got := udlGet(t, s, me.UID, "self", "profile:name")
|
||||||
|
if !got.Exists || got.Claim == nil || got.Claim.Value.Text != "Ada Lovelace" || got.Claim.Verify() != nil {
|
||||||
|
t.Fatalf("profile = %+v", got)
|
||||||
|
}
|
||||||
|
if seek := udlGet(t, s, me.UID, "bagcsaaa", "seek"); seek.Claim == nil || seek.Claim.Value.Kind != claim.KindBytes {
|
||||||
|
t.Fatalf("seek = %+v", seek)
|
||||||
|
}
|
||||||
|
if miss := udlGet(t, s, me.UID, "bagcsaaa", "nope"); miss.Exists || miss.Claim != nil {
|
||||||
|
t.Fatalf("missing cell = %+v", miss)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retraction: stored, returned with its ts, no longer "exists".
|
||||||
|
udlPutBatch(t, s, signed(t, me, "bagcsaaa", "like", nil, 200))
|
||||||
|
if tomb := udlGet(t, s, me.UID, "bagcsaaa", "like"); tomb.Exists || tomb.Claim == nil || tomb.Claim.TS != 200 {
|
||||||
|
t.Fatalf("tombstone = %+v", tomb)
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/udl/cid/self/aggregate?keys=profile:name", nil)
|
||||||
|
req = withVars(req, map[string]string{"cid": "self"})
|
||||||
|
s.handleUDLAggregate(rr, req)
|
||||||
|
if !strings.Contains(rr.Body.String(), `"sample_size":1`) {
|
||||||
|
t.Fatalf("aggregate = %s", rr.Body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **The load-bearing property of the batch endpoint: one stale cell does not
|
// A stale claim in a batch is reported in its slot without failing the rest.
|
||||||
// fail the batch.**
|
func TestUDLRecordsPut_StaleSlotDoesNotFailTheBatch(t *testing.T) {
|
||||||
//
|
|
||||||
// The single-record PUT answers a stale version with 409, which is right when
|
|
||||||
// the caller wrote one thing. In a batch it would be wrong: bulk verbs ("mark
|
|
||||||
// this whole series seen") routinely include episodes another device already
|
|
||||||
// marked, and 409-ing there would abandon every cell after it. So staleness is
|
|
||||||
// reported per record as accepted:false, with 200 on the request itself.
|
|
||||||
func TestUDLRecordsPut_StaleCellDoesNotFailTheBatch(t *testing.T) {
|
|
||||||
s := newUDLTestServer(t)
|
s := newUDLTestServer(t)
|
||||||
|
me := testSigner(t, 7)
|
||||||
const uid = "zCarol"
|
udlPutBatch(t, s, signed(t, me, "bagcsaaa", "seen:s01e01", "true", 400))
|
||||||
const cid = "bagdsaaaseries"
|
res := udlPutBatch(t, s,
|
||||||
// Seed s01e01 at version 4 — the batch below tries to write it at 2.
|
signed(t, me, "bagcsaaa", "seen:s01e01", "false", 300),
|
||||||
if ok, err := s.storage.UDLUpsertIfNewer(uid, cid, "seen:s01e01", 4, 100, "b2xk", "", false, false); err != nil || !ok {
|
signed(t, me, "bagcsaaa", "seen:s01e02", "true", 300),
|
||||||
t.Fatalf("seed: ok=%v err=%v", ok, err)
|
)
|
||||||
}
|
if res.Results[0].Accepted || res.Results[0].Status != "stale" || !res.Results[1].Accepted {
|
||||||
|
t.Fatalf("results = %+v", res.Results)
|
||||||
code, out := putRecords(t, s, `{"records":[
|
|
||||||
{"uid":"`+uid+`","cid":"`+cid+`","key":"seen:s01e01","version":2,"ts":200,"record":"c3RhbGU"},
|
|
||||||
{"uid":"`+uid+`","cid":"`+cid+`","key":"seen:s01e02","version":1,"ts":200,"record":"ZnJlc2g"}
|
|
||||||
]}`)
|
|
||||||
|
|
||||||
if code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200 — a stale cell is an outcome, not an error", code)
|
|
||||||
}
|
|
||||||
if len(out.Results) != 2 {
|
|
||||||
t.Fatalf("results = %d, want 2 (one per record, in order)", len(out.Results))
|
|
||||||
}
|
|
||||||
if out.Results[0].Accepted || out.Results[0].Error != "" {
|
|
||||||
t.Fatalf("stale cell = %+v, want accepted:false with no error", out.Results[0])
|
|
||||||
}
|
|
||||||
if !out.Results[1].Accepted {
|
|
||||||
t.Fatalf("fresh cell = %+v, want accepted:true", out.Results[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
// The fresh cell really landed, and the stale one did not overwrite.
|
|
||||||
if _, body := getRecord(t, s, uid, cid, "seen:s01e02"); string(body["exists"]) != "true" {
|
|
||||||
t.Fatalf("s01e02 was not written")
|
|
||||||
}
|
|
||||||
if _, body := getRecord(t, s, uid, cid, "seen:s01e01"); string(body["version"]) != "4" {
|
|
||||||
t.Fatalf("s01e01 version = %s, want 4 (the stale write must not apply)", body["version"])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A malformed record is reported in its own slot rather than rejecting the
|
|
||||||
// request, for the same reason: the caller retries that cell, not the batch.
|
|
||||||
func TestUDLRecordsPut_ReportsBadRecordPerSlot(t *testing.T) {
|
|
||||||
s := newUDLTestServer(t)
|
|
||||||
|
|
||||||
code, out := putRecords(t, s, `{"records":[
|
|
||||||
{"uid":"zDan","cid":"bagdsaaa","key":"","version":1,"ts":1,"record":"eA"},
|
|
||||||
{"uid":"zDan","cid":"bagdsaaa","key":"seen:s01e01","version":1,"ts":1,"record":"eA"}
|
|
||||||
]}`)
|
|
||||||
|
|
||||||
if code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", code)
|
|
||||||
}
|
|
||||||
if out.Results[0].Error == "" {
|
|
||||||
t.Fatalf("record with no key must report an error, got %+v", out.Results[0])
|
|
||||||
}
|
|
||||||
if !out.Results[1].Accepted {
|
|
||||||
t.Fatalf("the valid record beside it must still land, got %+v", out.Results[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An empty batch is a client bug, not a no-op: it almost always means the
|
|
||||||
// caller built its record list wrong, and answering 200 with an empty array
|
|
||||||
// would hide that.
|
|
||||||
func TestUDLRecordsPut_RejectsEmptyBatch(t *testing.T) {
|
func TestUDLRecordsPut_RejectsEmptyBatch(t *testing.T) {
|
||||||
s := newUDLTestServer(t)
|
s := newUDLTestServer(t)
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(`{"records":[]}`))
|
||||||
if code, _ := putRecords(t, s, `{"records":[]}`); code != http.StatusBadRequest {
|
rr := httptest.NewRecorder()
|
||||||
t.Fatalf("status = %d, want 400", code)
|
s.handleUDLRecordsPut(rr, req)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("empty batch = %d", rr.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,12 @@
|
|||||||
"rank": 5,
|
"rank": 5,
|
||||||
"note": "codec 0x100A + IDENTITY multihash 0x00, digest = varint(len(source)) || source || id - here (\"opensubtitles\",\"file:7061834\"). The GENERIC fetchable locator: one codec for every provider whose bytes are fetched the same way; the provider is inside the digest. Framing byte-identical to card (0x1007), but the byte path RESOLVES it (via source's fetch tier) instead of 404ing. Same codec-slot rule as the rest of the family: match on the CODEC, never the mh (identity 0x00 would fall through and score 0). Tier 5: once the fetched bytes' sha2-256 is linked onto the record it wins the canonical election with no addressing change."
|
"note": "codec 0x100A + IDENTITY multihash 0x00, digest = varint(len(source)) || source || id - here (\"opensubtitles\",\"file:7061834\"). The GENERIC fetchable locator: one codec for every provider whose bytes are fetched the same way; the provider is inside the digest. Framing byte-identical to card (0x1007), but the byte path RESOLVES it (via source's fetch tier) instead of 404ing. Same codec-slot rule as the rest of the family: match on the CODEC, never the mh (identity 0x00 would fall through and score 0). Tier 5: once the fetched bytes' sha2-256 is linked onto the record it wins the canonical election with no addressing change."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "provider-file-tachiyomi",
|
||||||
|
"cid": "bagfcaabebf2gcy3inf4w63ljnvwhu332gnvw423po42hg33ug4zdeytdonrtk4bsnq",
|
||||||
|
"rank": 5,
|
||||||
|
"note": "provider-file (0x100A) under the second registered source, (\"tachiyomi\", base32lower(sha256(\"https://www.mangakakalot.gg/manga/ragna-crimson/chapter-94\"))[..26] = \"mlzoz3knkow4sot722bcsc5p2l\"). A comic chapter, minted by meta-feeder-suwayomi from the chapter's address ON ITS SITE - never the Suwayomi host/port/row ids - so every box mints this same cid; redeemed into one CBZ. Same rank as any provider-file (rank ignores source); the vector proves the digest bytes (cid-formats.md section 8.3)."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "sha1",
|
"name": "sha1",
|
||||||
"cid": "baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra",
|
"cid": "baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra",
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// Package claim is the MMUR2 signed claim: signer uid says key = value about
|
||||||
|
// content cid, at ts (milliseconds, also the version).
|
||||||
|
//
|
||||||
|
// Wire spec (load-bearing for every implementation — Go here, Rust in
|
||||||
|
// meta-feeder-sdk's `claims` feature and the clients' record/ mirror, TS in
|
||||||
|
// the plugins' meta-core-client.ts; all pinned by testdata/claim-vectors.json):
|
||||||
|
//
|
||||||
|
// domain = "MMUR2\n"
|
||||||
|
// payload = canonical CBOR array [cid, uid, key, value, ts]
|
||||||
|
// bytes_to_sign = domain || payload
|
||||||
|
// sig = ECDSA-secp256k1(SHA-256(bytes_to_sign)), compact r||s, low-S
|
||||||
|
//
|
||||||
|
// value is CBOR text (public), bytes (private envelope) or null (retraction).
|
||||||
|
// Canonical = definite lengths, smallest integer heads — the encoder below is
|
||||||
|
// hand-rolled because those five shapes are all MMUR2 ever needs.
|
||||||
|
package claim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Domain separates MMUR2 bytes from every other signed payload (MMUR1 records,
|
||||||
|
// identity challenges). Changing it invalidates every claim everywhere.
|
||||||
|
const Domain = "MMUR2\n"
|
||||||
|
|
||||||
|
// Kind of a claim value. The SQLite storage type mirrors it: TEXT, BLOB, NULL.
|
||||||
|
type Kind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindNull Kind = iota
|
||||||
|
KindText
|
||||||
|
KindBytes
|
||||||
|
)
|
||||||
|
|
||||||
|
// Value is a claim's value. The zero Value is a retraction (null).
|
||||||
|
type Value struct {
|
||||||
|
Kind Kind
|
||||||
|
Text string
|
||||||
|
Bytes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func Text(s string) Value { return Value{Kind: KindText, Text: s} }
|
||||||
|
func Bytes(b []byte) Value { return Value{Kind: KindBytes, Bytes: b} }
|
||||||
|
func Null() Value { return Value{} }
|
||||||
|
func (v Value) IsNull() bool { return v.Kind == KindNull }
|
||||||
|
|
||||||
|
// Equal reports byte-for-byte equality, kind included.
|
||||||
|
func (v Value) Equal(o Value) bool {
|
||||||
|
if v.Kind != o.Kind {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch v.Kind {
|
||||||
|
case KindText:
|
||||||
|
return v.Text == o.Text
|
||||||
|
case KindBytes:
|
||||||
|
return string(v.Bytes) == string(o.Bytes)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim is one signed vote.
|
||||||
|
type Claim struct {
|
||||||
|
CID string
|
||||||
|
UID string
|
||||||
|
Key string
|
||||||
|
Value Value
|
||||||
|
TS uint64
|
||||||
|
Sig []byte // 64-byte compact r||s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignedBytes returns the exact bytes the signature covers.
|
||||||
|
func SignedBytes(cid, uid, key string, v Value, ts uint64) []byte {
|
||||||
|
b := make([]byte, 0, len(Domain)+len(cid)+len(uid)+len(key)+len(v.Text)+len(v.Bytes)+32)
|
||||||
|
b = append(b, Domain...)
|
||||||
|
b = appendHead(b, 4, 5) // array(5)
|
||||||
|
b = appendText(b, cid)
|
||||||
|
b = appendText(b, uid)
|
||||||
|
b = appendText(b, key)
|
||||||
|
switch v.Kind {
|
||||||
|
case KindText:
|
||||||
|
b = appendText(b, v.Text)
|
||||||
|
case KindBytes:
|
||||||
|
b = appendHead(b, 2, uint64(len(v.Bytes)))
|
||||||
|
b = append(b, v.Bytes...)
|
||||||
|
default:
|
||||||
|
b = append(b, 0xf6) // null
|
||||||
|
}
|
||||||
|
return appendHead(b, 0, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignedBytes of c.
|
||||||
|
func (c *Claim) SignedBytes() []byte {
|
||||||
|
return SignedBytes(c.CID, c.UID, c.Key, c.Value, c.TS)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendText(b []byte, s string) []byte {
|
||||||
|
b = appendHead(b, 3, uint64(len(s)))
|
||||||
|
return append(b, s...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendHead writes a CBOR major type + argument in its smallest form.
|
||||||
|
func appendHead(b []byte, major byte, n uint64) []byte {
|
||||||
|
m := major << 5
|
||||||
|
switch {
|
||||||
|
case n < 24:
|
||||||
|
return append(b, m|byte(n))
|
||||||
|
case n <= 0xff:
|
||||||
|
return append(b, m|24, byte(n))
|
||||||
|
case n <= 0xffff:
|
||||||
|
return append(b, m|25, byte(n>>8), byte(n))
|
||||||
|
case n <= 0xffffffff:
|
||||||
|
return append(b, m|26, byte(n>>24), byte(n>>16), byte(n>>8), byte(n))
|
||||||
|
default:
|
||||||
|
return append(b, m|27, byte(n>>56), byte(n>>48), byte(n>>40), byte(n>>32),
|
||||||
|
byte(n>>24), byte(n>>16), byte(n>>8), byte(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrBadSignature is returned for a signature that is malformed, high-S, or
|
||||||
|
// does not verify against the uid.
|
||||||
|
var ErrBadSignature = errors.New("bad signature")
|
||||||
|
|
||||||
|
// IsLowS reports whether a compact signature's S is in the lower half.
|
||||||
|
func IsLowS(sig []byte) bool {
|
||||||
|
if len(sig) != 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var s secp256k1.ModNScalar
|
||||||
|
if overflow := s.SetByteSlice(sig[32:]); overflow {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !s.IsOverHalfOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks the claim's shape and signature.
|
||||||
|
func (c *Claim) Verify() error {
|
||||||
|
if c.CID == "" || c.Key == "" || !identity.ValidUID(c.UID) {
|
||||||
|
return fmt.Errorf("%w: missing cid, key or valid uid", ErrBadSignature)
|
||||||
|
}
|
||||||
|
if len(c.Sig) != 64 || !IsLowS(c.Sig) {
|
||||||
|
return fmt.Errorf("%w: want a 64-byte low-S compact signature", ErrBadSignature)
|
||||||
|
}
|
||||||
|
if err := identity.Verify(c.UID, c.SignedBytes(), c.Sig); err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrBadSignature, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign fills c.Sig with id's compact low-S signature. Used by the account-key
|
||||||
|
// service (users who sign through meta-core until its separate ticket removes
|
||||||
|
// it), tests and the one-off migration tool — never by the claims engine.
|
||||||
|
func (c *Claim) Sign(id *identity.Identity) error {
|
||||||
|
if id.UID != c.UID {
|
||||||
|
return fmt.Errorf("claim uid %s is not the signing identity %s", c.UID, id.UID)
|
||||||
|
}
|
||||||
|
sig, err := identity.SignCompact(id, c.SignedBytes())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Sig = sig
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonClaim is the §3.4 HTTP form.
|
||||||
|
type jsonClaim struct {
|
||||||
|
CID string `json:"cid"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value json.RawMessage `json:"value"`
|
||||||
|
TS uint64 `json:"ts"`
|
||||||
|
Sig string `json:"sig"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonBytes struct {
|
||||||
|
B64 string `json:"b64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON emits {"cid","uid","key","value","ts","sig"}; bytes values as
|
||||||
|
// {"b64": "..."}, retractions as null.
|
||||||
|
func (c Claim) MarshalJSON() ([]byte, error) {
|
||||||
|
var v json.RawMessage
|
||||||
|
var err error
|
||||||
|
switch c.Value.Kind {
|
||||||
|
case KindText:
|
||||||
|
v, err = json.Marshal(c.Value.Text)
|
||||||
|
case KindBytes:
|
||||||
|
v, err = json.Marshal(jsonBytes{B64: base64.StdEncoding.EncodeToString(c.Value.Bytes)})
|
||||||
|
default:
|
||||||
|
v = json.RawMessage("null")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(jsonClaim{
|
||||||
|
CID: c.CID, UID: c.UID, Key: c.Key, Value: v, TS: c.TS,
|
||||||
|
Sig: base64.StdEncoding.EncodeToString(c.Sig),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON parses the §3.4 form.
|
||||||
|
func (c *Claim) UnmarshalJSON(data []byte) error {
|
||||||
|
var j jsonClaim
|
||||||
|
if err := json.Unmarshal(data, &j); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(j.Sig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sig: %w", err)
|
||||||
|
}
|
||||||
|
*c = Claim{CID: j.CID, UID: j.UID, Key: j.Key, TS: j.TS, Sig: sig}
|
||||||
|
raw := j.Value
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if raw[0] == '"' {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(raw, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Value = Text(s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var b jsonBytes
|
||||||
|
if err := json.Unmarshal(raw, &b); err != nil || b.B64 == "" && string(raw) != `{"b64":""}` {
|
||||||
|
return fmt.Errorf("value must be a string, {\"b64\": ...} or null")
|
||||||
|
}
|
||||||
|
bs, err := base64.StdEncoding.DecodeString(b.B64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("value.b64: %w", err)
|
||||||
|
}
|
||||||
|
c.Value = Bytes(bs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package claim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The golden file every MMUR2 implementation is pinned to. Regenerate with
|
||||||
|
// CLAIM_VECTORS_UPDATE=1 go test ./internal/claim/ — then re-sync the vendored
|
||||||
|
// copies (scripts/check-claim-vectors.sh --fix). A change here is a wire break.
|
||||||
|
const vectorsPath = "testdata/claim-vectors.json"
|
||||||
|
|
||||||
|
// Fixed test key: the scalar 0x0101…01. Never used outside tests.
|
||||||
|
const testKeyHex = "0101010101010101010101010101010101010101010101010101010101010101"
|
||||||
|
|
||||||
|
type vector struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
CID string `json:"cid"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value *string `json:"value"` // text value, or null
|
||||||
|
ValueB64 *string `json:"valueBytesHex,omitempty"` // bytes value as hex
|
||||||
|
TS uint64 `json:"ts"`
|
||||||
|
Signed string `json:"signedBytesHex"`
|
||||||
|
Sig string `json:"sigHex"` // deterministic RFC 6979, low-S
|
||||||
|
}
|
||||||
|
|
||||||
|
type vectorFile struct {
|
||||||
|
Comment string `json:"_comment"`
|
||||||
|
PrivateKeyHex string `json:"privateKeyHex"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Vectors []vector `json:"vectors"`
|
||||||
|
HighS struct {
|
||||||
|
Note string `json:"note"`
|
||||||
|
Vector string `json:"vector"`
|
||||||
|
SigHex string `json:"sigHex"`
|
||||||
|
} `json:"highS"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v vector) value(t *testing.T) Value {
|
||||||
|
t.Helper()
|
||||||
|
switch {
|
||||||
|
case v.ValueB64 != nil:
|
||||||
|
b, err := hex.DecodeString(*v.ValueB64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return Bytes(b)
|
||||||
|
case v.Value != nil:
|
||||||
|
return Text(*v.Value)
|
||||||
|
}
|
||||||
|
return Null()
|
||||||
|
}
|
||||||
|
|
||||||
|
func strp(s string) *string { return &s }
|
||||||
|
|
||||||
|
func testIdentity(t *testing.T) *identity.Identity {
|
||||||
|
t.Helper()
|
||||||
|
id, err := identity.Import(testKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func cases(uid string) []vector {
|
||||||
|
return []vector{
|
||||||
|
{Name: "text", CID: "bafkreih5kapbjzq", UID: uid, Key: "title", Value: strp("The Matrix"), TS: 1793001234567},
|
||||||
|
{Name: "empty-text", CID: "bafkreih5kapbjzq", UID: uid, Key: "title", Value: strp(""), TS: 1},
|
||||||
|
{Name: "unicode", CID: "bafkreih5kapbjzq", UID: uid, Key: "titles/jpn/攻殻機動隊", Value: strp("true"), TS: 1793001234567},
|
||||||
|
{Name: "keyset-member", CID: "bagacbabaec7v3", UID: uid, Key: "cids/bafkreih5kapbjzq", Value: strp("true"), TS: 23},
|
||||||
|
{Name: "bytes", CID: "bafkreih5kapbjzq", UID: uid, Key: "seek", ValueB64: strp("00ff10ab"), TS: 24},
|
||||||
|
{Name: "retraction", CID: "bafkreih5kapbjzq", UID: uid, Key: "genres/Drama", TS: 255},
|
||||||
|
{Name: "long-value", CID: "bafkreih5kapbjzq", UID: uid, Key: "plot/eng", Value: strp(strings.Repeat("a", 300)), TS: 65536},
|
||||||
|
{Name: "ts-64bit", CID: "bafkreih5kapbjzq", UID: uid, Key: "year", Value: strp("1999"), TS: 1 << 40},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVectors(t *testing.T) {
|
||||||
|
id := testIdentity(t)
|
||||||
|
if os.Getenv("CLAIM_VECTORS_UPDATE") == "1" {
|
||||||
|
writeVectors(t, id)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(vectorsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read vectors: %v (generate with CLAIM_VECTORS_UPDATE=1)", err)
|
||||||
|
}
|
||||||
|
var f vectorFile
|
||||||
|
if err := json.Unmarshal(raw, &f); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if f.UID != id.UID {
|
||||||
|
t.Fatalf("vector uid %s != test key uid %s", f.UID, id.UID)
|
||||||
|
}
|
||||||
|
for _, v := range f.Vectors {
|
||||||
|
c := Claim{CID: v.CID, UID: v.UID, Key: v.Key, Value: v.value(t), TS: v.TS}
|
||||||
|
if got := hex.EncodeToString(c.SignedBytes()); got != v.Signed {
|
||||||
|
t.Errorf("%s: signed bytes\n got %s\nwant %s", v.Name, got, v.Signed)
|
||||||
|
}
|
||||||
|
if err := c.Sign(id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := hex.EncodeToString(c.Sig); got != v.Sig {
|
||||||
|
t.Errorf("%s: sig\n got %s\nwant %s", v.Name, got, v.Sig)
|
||||||
|
}
|
||||||
|
if err := c.Verify(); err != nil {
|
||||||
|
t.Errorf("%s: verify: %v", v.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The high-S twin of a valid signature must be refused.
|
||||||
|
for _, v := range f.Vectors {
|
||||||
|
if v.Name != f.HighS.Vector {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sig, _ := hex.DecodeString(f.HighS.SigHex)
|
||||||
|
c := Claim{CID: v.CID, UID: v.UID, Key: v.Key, Value: v.value(t), TS: v.TS, Sig: sig}
|
||||||
|
if err := c.Verify(); !errors.Is(err, ErrBadSignature) {
|
||||||
|
t.Errorf("high-S signature accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeVectors(t *testing.T, id *identity.Identity) {
|
||||||
|
t.Helper()
|
||||||
|
f := vectorFile{
|
||||||
|
Comment: "MMUR2 golden vectors. Canonical copy: meta-core internal/claim/testdata. Do not edit by hand; see claim_test.go.",
|
||||||
|
PrivateKeyHex: testKeyHex,
|
||||||
|
UID: id.UID,
|
||||||
|
}
|
||||||
|
for _, v := range cases(id.UID) {
|
||||||
|
c := Claim{CID: v.CID, UID: v.UID, Key: v.Key, Value: v.value(t), TS: v.TS}
|
||||||
|
if err := c.Sign(id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v.Signed = hex.EncodeToString(c.SignedBytes())
|
||||||
|
v.Sig = hex.EncodeToString(c.Sig)
|
||||||
|
f.Vectors = append(f.Vectors, v)
|
||||||
|
}
|
||||||
|
first := f.Vectors[0]
|
||||||
|
sig, _ := hex.DecodeString(first.Sig)
|
||||||
|
f.HighS.Note = "same claim as `vector`, S replaced by n-S: valid ECDSA, but MMUR2 verifiers must refuse it"
|
||||||
|
f.HighS.Vector = first.Name
|
||||||
|
f.HighS.SigHex = hex.EncodeToString(highS(sig))
|
||||||
|
out, err := json.MarshalIndent(f, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll("testdata", 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(vectorsPath, append(out, '\n'), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func highS(sig []byte) []byte {
|
||||||
|
var s secp256k1.ModNScalar
|
||||||
|
s.SetByteSlice(sig[32:])
|
||||||
|
s.Negate()
|
||||||
|
out := append([]byte{}, sig[:32]...)
|
||||||
|
b := s.Bytes()
|
||||||
|
return append(out, b[:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONRoundTrip(t *testing.T) {
|
||||||
|
id := testIdentity(t)
|
||||||
|
for _, v := range []Value{Text("x"), Bytes([]byte{0, 1, 2}), Null()} {
|
||||||
|
c := Claim{CID: "bafy", UID: id.UID, Key: "k", Value: v, TS: 42}
|
||||||
|
if err := c.Sign(id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var back Claim
|
||||||
|
if err := json.Unmarshal(b, &back); err != nil {
|
||||||
|
t.Fatalf("%s: %v", b, err)
|
||||||
|
}
|
||||||
|
if !back.Value.Equal(v) || back.TS != 42 || string(back.Sig) != string(c.Sig) {
|
||||||
|
t.Errorf("round trip changed the claim: %s", b)
|
||||||
|
}
|
||||||
|
if err := back.Verify(); err != nil {
|
||||||
|
t.Errorf("%s: %v", b, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTamperRejected(t *testing.T) {
|
||||||
|
id := testIdentity(t)
|
||||||
|
c := Claim{CID: "bafy", UID: id.UID, Key: "title", Value: Text("A"), TS: 1}
|
||||||
|
if err := c.Sign(id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c.Value = Text("B")
|
||||||
|
if err := c.Verify(); !errors.Is(err, ErrBadSignature) {
|
||||||
|
t.Fatalf("tampered value verified: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"_comment": "MMUR2 golden vectors. Canonical copy: meta-core internal/claim/testdata. Do not edit by hand; see claim_test.go.",
|
||||||
|
"privateKeyHex": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||||
|
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
|
||||||
|
"vectors": [
|
||||||
|
{
|
||||||
|
"name": "text",
|
||||||
|
"cid": "bafkreih5kapbjzq",
|
||||||
|
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
|
||||||
|
"key": "title",
|
||||||
|
"value": "The Matrix",
|
||||||
|
"ts": 1793001234567,
|
||||||
|
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a657469746c656a546865204d61747269781b000001a17733a087",
|
||||||
|
"sigHex": "3ecac653f63cfc981564b5a5cc0db0459f2b4f19b810b6391d8f487216da0f3d2e560a2b61f4a81e320287cde753e1990d7949f4f518127f6505b8fb698ee8a8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "empty-text",
|
||||||
|
"cid": "bafkreih5kapbjzq",
|
||||||
|
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
|
||||||
|
"key": "title",
|
||||||
|
"value": "",
|
||||||
|
"ts": 1,
|
||||||
|
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a657469746c656001",
|
||||||
|
"sigHex": "6eeb9ace8afe3987006c166eb0ec564b71e23d6a1c6211fdd55e8b4779c12dbb4f38c443faa3255c2ce7c6592e9d024e4ea57b5fa23446d1d4df647947e37642"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unicode",
|
||||||
|
"cid": "bafkreih5kapbjzq",
|
||||||
|
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
|
||||||
|
"key": "titles/jpn/攻殻機動隊",
|
||||||
|
"value": "true",
|
||||||
|
"ts": 1793001234567,
|
||||||
|
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a781a7469746c65732f6a706e2fe694bbe6aebbe6a99fe58b95e99a8a64747275651b000001a17733a087",
|
||||||
|
"sigHex": "64ec6342c598782df278b49636a34da4f4c009423a584454a3284efad5426aa517d83873b09a4fdd3fd32a5e9a5fef0880126edd11db773045caeb6e634bc207"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "keyset-member",
|
||||||
|
"cid": "bagacbabaec7v3",
|
||||||
|
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
|
||||||
|
"key": "cids/bafkreih5kapbjzq",
|
||||||
|
"value": "true",
|
||||||
|
"ts": 23,
|
||||||
|
"signedBytesHex": "4d4d5552320a856e6261676163626162616563377633782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a75636964732f6261666b72656968356b6170626a7a71647472756517",
|
||||||
|
"sigHex": "8f707847895ccb522f1507dbab23330bd2c567d6fd1882df3bfae65bef6601024832f0b181146dcfb089aae91f5beac47da970373ab14370c4ac41410f3bf761"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bytes",
|
||||||
|
"cid": "bafkreih5kapbjzq",
|
||||||
|
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
|
||||||
|
"key": "seek",
|
||||||
|
"value": null,
|
||||||
|
"valueBytesHex": "00ff10ab",
|
||||||
|
"ts": 24,
|
||||||
|
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a647365656b4400ff10ab1818",
|
||||||
|
"sigHex": "09671b27c03edd510029ec550b35e95e3900dd37a5d688ea04c3f0409979c7236bc4a1b5fa9d1b76265da85df24693a51f0ced528a6171c90a70b92db9f246e4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "retraction",
|
||||||
|
"cid": "bafkreih5kapbjzq",
|
||||||
|
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
|
||||||
|
"key": "genres/Drama",
|
||||||
|
"value": null,
|
||||||
|
"ts": 255,
|
||||||
|
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a6c67656e7265732f4472616d61f618ff",
|
||||||
|
"sigHex": "d0c29d8edac4b78005eaf895cd9d534cb080b22ba9f7e79a5abf4d9bd906dc494464a2b388653452bca16e351132a9a92370953fa047e693459e55b7965449ac"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "long-value",
|
||||||
|
"cid": "bafkreih5kapbjzq",
|
||||||
|
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
|
||||||
|
"key": "plot/eng",
|
||||||
|
"value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"ts": 65536,
|
||||||
|
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a68706c6f742f656e6779012c6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161611a00010000",
|
||||||
|
"sigHex": "383b2e40f913eb5240e1eae2cfa392f967321d3701623c2b3df344984686d888198bce1c78847d1898b35f991ec3465c382c45e03b9a77f2ae7d1fd740391dd2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ts-64bit",
|
||||||
|
"cid": "bafkreih5kapbjzq",
|
||||||
|
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
|
||||||
|
"key": "year",
|
||||||
|
"value": "1999",
|
||||||
|
"ts": 1099511627776,
|
||||||
|
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a647965617264313939391b0000010000000000",
|
||||||
|
"sigHex": "2b9b42ef4ebd7fc8d3ca0a514679ca4104f2e627da3a84c7b9664b27ebbdf73946cc37a04e4ea15dbed23a0e48e8797c92fcba5598b012305a314bf776398e0d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"highS": {
|
||||||
|
"note": "same claim as `vector`, S replaced by n-S: valid ECDSA, but MMUR2 verifiers must refuse it",
|
||||||
|
"vector": "text",
|
||||||
|
"sigHex": "3ecac653f63cfc981564b5a5cc0db0459f2b4f19b810b6391d8f487216da0f3dd1a9f5d49e0b57e1cdfd783218ac1e65ad3592f1ba308dbc5acca59166a75899"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,9 @@ type Config struct {
|
|||||||
BaseURL string // Base URL for stable service discovery
|
BaseURL string // Base URL for stable service discovery
|
||||||
PublicURL string // Browser-facing URL for the service-bar nav; overrides BaseURL when set (e.g. a debug-direct port with no Caddy in front)
|
PublicURL string // Browser-facing URL for the service-bar nav; overrides BaseURL when set (e.g. a debug-direct port with no Caddy in front)
|
||||||
|
|
||||||
// Redis configuration
|
// Storage configuration
|
||||||
RedisPort int // Redis port (default: 6379)
|
DBPathOverride string // META_CORE_DB_PATH; empty = <MetaCorePath>/db/meta-core.db (truth)
|
||||||
|
CachePathOverride string // META_CORE_CACHE_PATH; empty = <MetaCorePath>/db-cache/cache.db
|
||||||
|
|
||||||
// HTTP API configuration
|
// HTTP API configuration
|
||||||
HTTPPort int // HTTP API port (default: 9000)
|
HTTPPort int // HTTP API port (default: 9000)
|
||||||
@@ -60,7 +61,8 @@ func Load() *Config {
|
|||||||
APIPort: getEnvInt("API_PORT", 8180),
|
APIPort: getEnvInt("API_PORT", 8180),
|
||||||
BaseURL: getEnv("BASE_URL", ""),
|
BaseURL: getEnv("BASE_URL", ""),
|
||||||
PublicURL: getEnv("META_CORE_PUBLIC_URL", ""),
|
PublicURL: getEnv("META_CORE_PUBLIC_URL", ""),
|
||||||
RedisPort: getEnvInt("REDIS_PORT", 6379),
|
DBPathOverride: getEnv("META_CORE_DB_PATH", ""),
|
||||||
|
CachePathOverride: getEnv("META_CORE_CACHE_PATH", ""),
|
||||||
HTTPPort: getEnvInt("META_CORE_HTTP_PORT", 9000),
|
HTTPPort: getEnvInt("META_CORE_HTTP_PORT", 9000),
|
||||||
HTTPHost: getEnv("META_CORE_HTTP_HOST", "0.0.0.0"),
|
HTTPHost: getEnv("META_CORE_HTTP_HOST", "0.0.0.0"),
|
||||||
HealthCheckIntervalMS: getEnvInt("HEALTH_CHECK_INTERVAL_MS", 5000),
|
HealthCheckIntervalMS: getEnvInt("HEALTH_CHECK_INTERVAL_MS", 5000),
|
||||||
@@ -83,16 +85,29 @@ func Load() *Config {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// LockFilePath returns the path to the Redis-volume mutex. Held by
|
// LockFilePath returns the path to the database-volume mutex (one meta-core
|
||||||
|
// per database file). Held by
|
||||||
// docker/leader-election.sh; nothing reads it, and it is NOT service
|
// docker/leader-election.sh; nothing reads it, and it is NOT service
|
||||||
// discovery — see docs/project-architecture/beacon-v2.md.
|
// discovery — see docs/project-architecture/beacon-v2.md.
|
||||||
func (c *Config) LockFilePath() string {
|
func (c *Config) LockFilePath() string {
|
||||||
return c.MetaCorePath + "/locks/kv-leader.lock"
|
return c.MetaCorePath + "/locks/kv-leader.lock"
|
||||||
}
|
}
|
||||||
|
|
||||||
// RedisDataDir returns the path to Redis data directory
|
// DBPath returns the path of meta-core's truth database (backed up).
|
||||||
func (c *Config) RedisDataDir() string {
|
func (c *Config) DBPath() string {
|
||||||
return c.MetaCorePath + "/db/redis"
|
if c.DBPathOverride != "" {
|
||||||
|
return c.DBPathOverride
|
||||||
|
}
|
||||||
|
return c.MetaCorePath + "/db/meta-core.db"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CachePath returns the path of meta-core's cache database: everything
|
||||||
|
// derived from truth, never backed up, rebuilt when missing.
|
||||||
|
func (c *Config) CachePath() string {
|
||||||
|
if c.CachePathOverride != "" {
|
||||||
|
return c.CachePathOverride
|
||||||
|
}
|
||||||
|
return c.MetaCorePath + "/db-cache/cache.db"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MountsFilePath returns the path to the mounts configuration file
|
// MountsFilePath returns the path to the mounts configuration file
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
package events
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
|
|
||||||
"github.com/metazla/meta-core/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// MetaEventsStream is the Redis stream name for metadata events
|
|
||||||
MetaEventsStream = "meta:events"
|
|
||||||
|
|
||||||
// MetaEventsMaxLen is the approximate cap on stream length. Bounded
|
|
||||||
// retention is a contract requirement for the SSE wire — see
|
|
||||||
// docs/api-mediated-access.md "Backing-store retention and lifecycle".
|
|
||||||
// Cheap (~) trim keeps growth bounded without serialising writes.
|
|
||||||
MetaEventsMaxLen = 100_000
|
|
||||||
)
|
|
||||||
|
|
||||||
// MetaPublisher subscribes to Redis keyspace notifications
|
|
||||||
// and publishes metadata change events to the meta:events stream
|
|
||||||
type MetaPublisher struct {
|
|
||||||
client *redis.Client
|
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
|
||||||
wg sync.WaitGroup
|
|
||||||
running bool
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMetaPublisher creates a new MetaPublisher
|
|
||||||
func NewMetaPublisher(client *redis.Client) *MetaPublisher {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
return &MetaPublisher{
|
|
||||||
client: client,
|
|
||||||
ctx: ctx,
|
|
||||||
cancel: cancel,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start begins subscribing to keyspace notifications.
|
|
||||||
//
|
|
||||||
// The stream is NOT truncated on startup — the SSE wire contract requires
|
|
||||||
// Last-Event-ID resumption to survive a meta-core restart, which means
|
|
||||||
// entries must persist across restart. Growth is bounded instead by
|
|
||||||
// MAXLEN ~ MetaEventsMaxLen on every XAdd (see publishEvent).
|
|
||||||
func (p *MetaPublisher) Start() error {
|
|
||||||
p.mu.Lock()
|
|
||||||
if p.running {
|
|
||||||
p.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
p.running = true
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
p.wg.Add(1)
|
|
||||||
go p.subscribeLoop()
|
|
||||||
|
|
||||||
log.Println("[MetaPublisher] Started - listening for keyspace notifications")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops the publisher
|
|
||||||
func (p *MetaPublisher) Stop() error {
|
|
||||||
p.mu.Lock()
|
|
||||||
if !p.running {
|
|
||||||
p.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
p.running = false
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
p.cancel()
|
|
||||||
p.wg.Wait()
|
|
||||||
|
|
||||||
log.Println("[MetaPublisher] Stopped")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsRunning returns true if the publisher is running
|
|
||||||
func (p *MetaPublisher) IsRunning() bool {
|
|
||||||
p.mu.RLock()
|
|
||||||
defer p.mu.RUnlock()
|
|
||||||
return p.running
|
|
||||||
}
|
|
||||||
|
|
||||||
// subscribeLoop subscribes to keyspace notifications and publishes events
|
|
||||||
func (p *MetaPublisher) subscribeLoop() {
|
|
||||||
defer p.wg.Done()
|
|
||||||
|
|
||||||
// Subscribe to all file:* keyspace events
|
|
||||||
// Pattern: __keyspace@0__:file:*
|
|
||||||
pattern := "__keyspace@0__:file:*"
|
|
||||||
pubsub := p.client.PSubscribe(p.ctx, pattern)
|
|
||||||
defer pubsub.Close()
|
|
||||||
|
|
||||||
log.Printf("[MetaPublisher] Subscribed to pattern: %s", pattern)
|
|
||||||
|
|
||||||
ch := pubsub.Channel()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-p.ctx.Done():
|
|
||||||
return
|
|
||||||
case msg := <-ch:
|
|
||||||
if msg == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// msg.Channel: __keyspace@0__:file:midhash256:abc123/tmdb
|
|
||||||
// msg.Payload: set, del, expire, etc.
|
|
||||||
p.publishEvent(msg.Channel, msg.Payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// publishEvent publishes a keyspace event to the meta:events stream
|
|
||||||
func (p *MetaPublisher) publishEvent(channel, operation string) {
|
|
||||||
// Extract key from channel: __keyspace@0__:file:xxx/field -> file:xxx/field
|
|
||||||
key := strings.TrimPrefix(channel, "__keyspace@0__:")
|
|
||||||
|
|
||||||
// Skip index key and non-file keys
|
|
||||||
if strings.Contains(key, "__index__") || !strings.HasPrefix(key, "file:") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Skip the per-record field index. It is bookkeeping (see
|
|
||||||
// storage.FieldsField), not a metadata field — publishing it would make
|
|
||||||
// every downstream consumer that parses `file:<id>/<field>` off this
|
|
||||||
// stream (meta-search, meta-dup, meta-fuse, meta-sort) see a phantom
|
|
||||||
// field change on a key they can never read.
|
|
||||||
if strings.HasSuffix(key, "/"+storage.FieldsField) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build event fields
|
|
||||||
fields := map[string]interface{}{
|
|
||||||
"type": operation, // "set", "del", "expire"
|
|
||||||
"key": key, // "file:midhash256:abc123/tmdb"
|
|
||||||
"ts": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Approximate MAXLEN trim keeps the stream bounded across restarts
|
|
||||||
// (see Start). Cap is intentionally generous; most consumers reconnect
|
|
||||||
// well within retention.
|
|
||||||
_, err := p.client.XAdd(p.ctx, &redis.XAddArgs{
|
|
||||||
Stream: MetaEventsStream,
|
|
||||||
MaxLen: MetaEventsMaxLen,
|
|
||||||
Approx: true,
|
|
||||||
Values: fields,
|
|
||||||
}).Result()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[MetaPublisher] Error publishing event: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// publishEventDirect publishes an event directly (not from keyspace notification)
|
|
||||||
func (p *MetaPublisher) publishEventDirect(operation, key string) {
|
|
||||||
fields := map[string]interface{}{
|
|
||||||
"type": operation,
|
|
||||||
"key": key,
|
|
||||||
"ts": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := p.client.XAdd(p.ctx, &redis.XAddArgs{
|
|
||||||
Stream: MetaEventsStream,
|
|
||||||
MaxLen: MetaEventsMaxLen,
|
|
||||||
Approx: true,
|
|
||||||
Values: fields,
|
|
||||||
}).Result()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[MetaPublisher] Error publishing direct event: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RepublishFunc is a function type for getting metadata that can be republished
|
|
||||||
type RepublishFunc func() (hashIDs []string, getMetadata func(hashID string) (map[string]string, error), err error)
|
|
||||||
|
|
||||||
// RepublishAllMetadata iterates over all existing file metadata and publishes events
|
|
||||||
// This is called on startup and can be triggered via API
|
|
||||||
func (p *MetaPublisher) RepublishAllMetadata(republishFunc RepublishFunc) (int, error) {
|
|
||||||
p.mu.RLock()
|
|
||||||
running := p.running
|
|
||||||
p.mu.RUnlock()
|
|
||||||
|
|
||||||
if !running {
|
|
||||||
return 0, fmt.Errorf("publisher not running")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all hash IDs and metadata getter
|
|
||||||
hashIDs, getMetadata, err := republishFunc()
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to get hash IDs: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[MetaPublisher] Republishing events for %d files...", len(hashIDs))
|
|
||||||
|
|
||||||
publishedCount := 0
|
|
||||||
for _, hashID := range hashIDs {
|
|
||||||
// Get all properties for this file
|
|
||||||
metadata, err := getMetadata(hashID)
|
|
||||||
if err != nil || metadata == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish a set event for each property
|
|
||||||
for property := range metadata {
|
|
||||||
key := fmt.Sprintf("file:%s/%s", hashID, property)
|
|
||||||
p.publishEventDirect("set", key)
|
|
||||||
publishedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[MetaPublisher] Republished %d property events", publishedCount)
|
|
||||||
return publishedCount, nil
|
|
||||||
}
|
|
||||||
@@ -307,6 +307,30 @@ func Sign(id *Identity, bytes []byte) ([]byte, error) {
|
|||||||
return sig.Serialize(), nil
|
return sig.Serialize(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SignCompact is Sign with the 64-byte compact r||s encoding MMUR2 claims
|
||||||
|
// use. decred's RFC 6979 signer always emits low-S, which MMUR2 requires.
|
||||||
|
func SignCompact(id *Identity, bytes []byte) ([]byte, error) {
|
||||||
|
if id == nil {
|
||||||
|
return nil, errors.New("no identity loaded")
|
||||||
|
}
|
||||||
|
raw, err := hex.DecodeString(id.PrivateKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode private key: %w", err)
|
||||||
|
}
|
||||||
|
if len(raw) != 32 {
|
||||||
|
return nil, fmt.Errorf("private key length %d", len(raw))
|
||||||
|
}
|
||||||
|
priv := secp256k1.PrivKeyFromBytes(raw)
|
||||||
|
digest := sha256.Sum256(bytes)
|
||||||
|
sig := ecdsa.Sign(priv, digest[:])
|
||||||
|
r, s := sig.R(), sig.S()
|
||||||
|
out := make([]byte, 64)
|
||||||
|
rb, sb := r.Bytes(), s.Bytes()
|
||||||
|
copy(out[:32], rb[:])
|
||||||
|
copy(out[32:], sb[:])
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeriveAEADKey derives the symmetric key used to encrypt private-tier
|
// DeriveAEADKey derives the symmetric key used to encrypt private-tier
|
||||||
// User Data Layer records. The KDF parameters (salt, info, length) are
|
// User Data Layer records. The KDF parameters (salt, info, length) are
|
||||||
// fixed for the v1 wire format.
|
// fixed for the v1 wire format.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func ValidAction(a Action) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ValidUID exposes the uid shape check to callers outside this package (the
|
// ValidUID exposes the uid shape check to callers outside this package (the
|
||||||
// API layer, which must reject a hostile uid before it reaches a Redis SCAN
|
// API layer, which must reject a hostile uid before it reaches a key-range read
|
||||||
// pattern or a file path).
|
// pattern or a file path).
|
||||||
func ValidUID(uid string) bool { return validUID(uid) }
|
func ValidUID(uid string) bool { return validUID(uid) }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,869 @@
|
|||||||
|
// Package kv is meta-core's storage engine: one string→string table in an
|
||||||
|
// embedded SQLite file.
|
||||||
|
//
|
||||||
|
// # Why a key/value table and not a schema
|
||||||
|
//
|
||||||
|
// meta-core's data model is schema-free on purpose — a record is a set of flat
|
||||||
|
// `file:<rid>/<field>` keys, and a new metadata field is just a new key
|
||||||
|
// (METADATA_KEYS.md). The engine keeps exactly that model:
|
||||||
|
//
|
||||||
|
// CREATE TABLE kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID
|
||||||
|
//
|
||||||
|
// is the only table that holds data, and it never needs a migration. What
|
||||||
|
// Redis gave us that a bare KV would not — "list a record's fields", "find
|
||||||
|
// records by field value", free-text search — comes from the table being
|
||||||
|
// *ordered* (a record is a key range) and from two derived structures the
|
||||||
|
// storage package maintains on top: an expression index over the field name
|
||||||
|
// parsed out of the key, and an FTS5 table. Both are rebuildable from the kv
|
||||||
|
// rows and never authoritative.
|
||||||
|
//
|
||||||
|
// It replaced Redis on 2026-10: holding everything in RAM cost ~6.6 GB on a
|
||||||
|
// 169k-record corpus (Redis plus the API's in-memory copy needed to search
|
||||||
|
// it), with the OOM kills that implies. The same data is ~2.5 GB on disk here
|
||||||
|
// and the working set is a bounded page cache.
|
||||||
|
//
|
||||||
|
// # Concurrency
|
||||||
|
//
|
||||||
|
// SQLite allows one writer at a time, so every write goes through one writer
|
||||||
|
// goroutine owning one connection. Concurrent Update calls are GROUP-COMMITTED:
|
||||||
|
// the writer runs every request waiting at that moment inside one SQLite
|
||||||
|
// transaction, each under its own SAVEPOINT (a failing request rolls back
|
||||||
|
// alone), and commits once. A commit — writing the dirty pages of the table,
|
||||||
|
// the field index and the FTS shadow tables to the WAL — was ~40% of the cost
|
||||||
|
// of a record write, so sharing it is what makes concurrent ingest scale.
|
||||||
|
//
|
||||||
|
// Reads use a separate pool and see the last committed state (WAL). Inside
|
||||||
|
// Update, read through the *Tx — the reader pool cannot see uncommitted rows —
|
||||||
|
// and never call Update from inside an Update callback (the writer would wait
|
||||||
|
// for itself).
|
||||||
|
//
|
||||||
|
// # Events
|
||||||
|
//
|
||||||
|
// Redis keyspace notifications are replaced by the transaction itself: every
|
||||||
|
// Put/Delete of a key the Options.EventKey predicate selects is appended, in
|
||||||
|
// the same transaction, to Options.EventStream as {type: set|del, key, ts}.
|
||||||
|
// A change and its event therefore commit together or not at all.
|
||||||
|
package kv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options configures a Store.
|
||||||
|
type Options struct {
|
||||||
|
// EventStream receives a set/del entry for every changed key EventKey
|
||||||
|
// selects. Empty disables change events.
|
||||||
|
EventStream string
|
||||||
|
// EventMaxLen is the approximate retention of EventStream.
|
||||||
|
EventMaxLen int64
|
||||||
|
// EventKey selects the keys whose changes are published.
|
||||||
|
EventKey func(key string) bool
|
||||||
|
// WriterCacheMB / ReaderCacheMB size SQLite's page cache per connection.
|
||||||
|
WriterCacheMB int
|
||||||
|
ReaderCacheMB int
|
||||||
|
// Readers is the size of the read connection pool.
|
||||||
|
Readers int
|
||||||
|
// Schema is extra DDL run at open (idempotent CREATE … IF NOT EXISTS),
|
||||||
|
// for derived indexes owned by the caller.
|
||||||
|
Schema string
|
||||||
|
// OnChange is called inside the transaction for every key whose value
|
||||||
|
// actually changed (created, modified or deleted), with the old and new
|
||||||
|
// values — the hook derived counters hang off (Tx.AddCounter). It must not
|
||||||
|
// write kv rows itself.
|
||||||
|
OnChange func(tx *Tx, key, old string, hadOld bool, new string, hasNew bool)
|
||||||
|
// NoKVTable opens the file without the kv table and its prepared
|
||||||
|
// statements: the caller owns every table through Schema and writes them
|
||||||
|
// with Tx.Exec / Tx.Query (meta-core's truth database). The kv methods
|
||||||
|
// (Get, Put, streams, counters) must not be used on such a store.
|
||||||
|
NoKVTable bool
|
||||||
|
// Synchronous is SQLite's synchronous pragma; default NORMAL. A store that
|
||||||
|
// is rebuildable from another (meta-core's cache) runs OFF.
|
||||||
|
Synchronous string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store is an open database.
|
||||||
|
type Store struct {
|
||||||
|
path string
|
||||||
|
opts Options
|
||||||
|
w *sql.DB // single connection: the one writer
|
||||||
|
r *sql.DB // read pool
|
||||||
|
|
||||||
|
streamMu sync.Mutex
|
||||||
|
streams map[string]*streamState
|
||||||
|
|
||||||
|
// Hot write statements, prepared once on the writer connection: parsing
|
||||||
|
// SQL on every Put was the single largest cost of a record write.
|
||||||
|
stPut, stPutIfAbsent, stDelete, stGet, stInsert *sql.Stmt
|
||||||
|
|
||||||
|
// Write accounting: how many Puts changed a value vs found it unchanged.
|
||||||
|
puts, noops atomic.Int64
|
||||||
|
|
||||||
|
writes chan *writeReq
|
||||||
|
writeMu sync.Mutex // held by the writer while a group runs
|
||||||
|
closeOnce sync.Once
|
||||||
|
stopped chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeReq is one Update call waiting for the writer.
|
||||||
|
type writeReq struct {
|
||||||
|
fn func(tx *Tx) error
|
||||||
|
done chan error
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxGroup caps how many requests share one commit, bounding how long the
|
||||||
|
// writer holds a transaction open.
|
||||||
|
const maxGroup = 128
|
||||||
|
|
||||||
|
// Open opens (creating if needed) the database file at path.
|
||||||
|
func Open(path string, opts Options) (*Store, error) {
|
||||||
|
if opts.Readers <= 0 {
|
||||||
|
opts.Readers = 8
|
||||||
|
}
|
||||||
|
if opts.WriterCacheMB <= 0 {
|
||||||
|
opts.WriterCacheMB = 64
|
||||||
|
}
|
||||||
|
if opts.ReaderCacheMB <= 0 {
|
||||||
|
opts.ReaderCacheMB = 16
|
||||||
|
}
|
||||||
|
if opts.EventMaxLen <= 0 {
|
||||||
|
opts.EventMaxLen = 100_000
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Synchronous == "" {
|
||||||
|
opts.Synchronous = "NORMAL"
|
||||||
|
}
|
||||||
|
w, err := sql.Open("sqlite", dsn(path, opts.WriterCacheMB, false, opts.Synchronous))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open writer: %w", err)
|
||||||
|
}
|
||||||
|
w.SetMaxOpenConns(1)
|
||||||
|
w.SetMaxIdleConns(1)
|
||||||
|
w.SetConnMaxLifetime(0)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if !opts.NoKVTable {
|
||||||
|
if _, err := w.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID`); err != nil {
|
||||||
|
w.Close()
|
||||||
|
return nil, fmt.Errorf("create kv: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.Schema != "" {
|
||||||
|
// No deadline: on an existing store, the first open after a new index
|
||||||
|
// is added builds it over every row (minutes at millions of keys).
|
||||||
|
// Cancelling that just rolls it back and restarts it on the next boot.
|
||||||
|
started := time.Now()
|
||||||
|
if _, err := w.ExecContext(context.Background(), opts.Schema); err != nil {
|
||||||
|
w.Close()
|
||||||
|
return nil, fmt.Errorf("apply schema: %w", err)
|
||||||
|
}
|
||||||
|
if d := time.Since(started); d > 5*time.Second {
|
||||||
|
log.Printf("[kv] schema applied in %s (indexes built)", d.Round(time.Second))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := sql.Open("sqlite", dsn(path, opts.ReaderCacheMB, true, opts.Synchronous))
|
||||||
|
if err != nil {
|
||||||
|
w.Close()
|
||||||
|
return nil, fmt.Errorf("open readers: %w", err)
|
||||||
|
}
|
||||||
|
r.SetMaxOpenConns(opts.Readers)
|
||||||
|
r.SetMaxIdleConns(opts.Readers)
|
||||||
|
|
||||||
|
st := &Store{path: path, opts: opts, w: w, r: r, streams: make(map[string]*streamState)}
|
||||||
|
// Fresh deadline: the schema step above may legitimately have taken minutes.
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
stmts := []struct {
|
||||||
|
dst **sql.Stmt
|
||||||
|
query string
|
||||||
|
}{
|
||||||
|
{&st.stPut, `INSERT INTO kv(k, v) VALUES(?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v`},
|
||||||
|
{&st.stPutIfAbsent, `INSERT INTO kv(k, v) VALUES(?, ?) ON CONFLICT(k) DO NOTHING`},
|
||||||
|
{&st.stDelete, `DELETE FROM kv WHERE k = ? RETURNING v`},
|
||||||
|
{&st.stGet, `SELECT v FROM kv WHERE k = ?`},
|
||||||
|
{&st.stInsert, `INSERT INTO kv(k, v) VALUES(?, ?)`},
|
||||||
|
}
|
||||||
|
if opts.NoKVTable {
|
||||||
|
stmts = nil
|
||||||
|
}
|
||||||
|
for _, p := range stmts {
|
||||||
|
if *p.dst, err = w.PrepareContext(ctx, p.query); err != nil {
|
||||||
|
r.Close()
|
||||||
|
w.Close()
|
||||||
|
return nil, fmt.Errorf("prepare: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.writes = make(chan *writeReq)
|
||||||
|
st.stopped = make(chan struct{})
|
||||||
|
go st.writer()
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dsn builds a modernc DSN. WAL + synchronous=NORMAL is the durability class
|
||||||
|
// Redis' AOF-everysec gave us: a power loss may drop the last transactions,
|
||||||
|
// never corrupt the file.
|
||||||
|
func dsn(path string, cacheMB int, readOnly bool, synchronous string) string {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Add("_pragma", "busy_timeout(30000)")
|
||||||
|
q.Add("_pragma", "journal_mode(WAL)")
|
||||||
|
q.Add("_pragma", "synchronous("+synchronous+")")
|
||||||
|
q.Add("_pragma", fmt.Sprintf("cache_size(-%d)", cacheMB*1024))
|
||||||
|
q.Add("_pragma", "temp_store(MEMORY)")
|
||||||
|
q.Add("_pragma", "journal_size_limit(268435456)")
|
||||||
|
if readOnly {
|
||||||
|
q.Add("_pragma", "query_only(1)")
|
||||||
|
} else {
|
||||||
|
q.Add("_txlock", "immediate")
|
||||||
|
}
|
||||||
|
return "file:" + path + "?" + q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the writer and closes both pools, checkpointing the WAL into the
|
||||||
|
// main file.
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
s.closeOnce.Do(func() { close(s.stopped) })
|
||||||
|
s.writeMu.Lock() // wait for an in-flight group to finish
|
||||||
|
defer s.writeMu.Unlock()
|
||||||
|
_, _ = s.w.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
|
||||||
|
return errors.Join(s.r.Close(), s.w.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping reports whether the database answers.
|
||||||
|
func (s *Store) Ping(ctx context.Context) error {
|
||||||
|
var one int
|
||||||
|
return s.r.QueryRowContext(ctx, `SELECT 1`).Scan(&one)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path is the database file path.
|
||||||
|
func (s *Store) Path() string { return s.path }
|
||||||
|
|
||||||
|
// DB exposes the read pool for callers that own derived indexes (search). It
|
||||||
|
// is read-only (query_only), so it cannot bypass Update.
|
||||||
|
func (s *Store) DB() *sql.DB { return s.r }
|
||||||
|
|
||||||
|
// FileSize returns the on-disk size of the database plus its WAL.
|
||||||
|
func (s *Store) FileSize() int64 {
|
||||||
|
var total int64
|
||||||
|
for _, suffix := range []string{"", "-wal"} {
|
||||||
|
if fi, err := os.Stat(s.path + suffix); err == nil {
|
||||||
|
total += fi.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkpoint folds the WAL back into the main file. TRUNCATE takes the writer
|
||||||
|
// slot and waits for readers, so callers run it when idle.
|
||||||
|
func (s *Store) Checkpoint(ctx context.Context) error {
|
||||||
|
_, err := s.w.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// KV is one key/value pair.
|
||||||
|
type KV struct {
|
||||||
|
K, V string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reader is the read surface shared by Store (committed state) and Tx (the
|
||||||
|
// transaction's own view). Storage helpers take a Reader so they work both
|
||||||
|
// inside and outside a write.
|
||||||
|
type Reader interface {
|
||||||
|
Get(ctx context.Context, k string) (string, bool, error)
|
||||||
|
MGet(ctx context.Context, keys []string) (map[string]string, error)
|
||||||
|
Range(ctx context.Context, prefix, after string, limit int) ([]KV, error)
|
||||||
|
Count(ctx context.Context, prefix string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type queryer interface {
|
||||||
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||||
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrefixEnd returns the smallest string greater than every string with the
|
||||||
|
// given prefix, so [prefix, PrefixEnd(prefix)) is exactly the prefix's range.
|
||||||
|
// An empty result means "unbounded".
|
||||||
|
func PrefixEnd(prefix string) string {
|
||||||
|
b := []byte(prefix)
|
||||||
|
for i := len(b) - 1; i >= 0; i-- {
|
||||||
|
if b[i] < 0xff {
|
||||||
|
b[i]++
|
||||||
|
return string(b[:i+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(ctx context.Context, q queryer, k string) (string, bool, error) {
|
||||||
|
var v string
|
||||||
|
err := q.QueryRowContext(ctx, `SELECT v FROM kv WHERE k = ?`, k).Scan(&v)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return v, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mgetBatch stays well under SQLite's default 32766 bound-parameter cap.
|
||||||
|
const mgetBatch = 500
|
||||||
|
|
||||||
|
func mget(ctx context.Context, q queryer, keys []string) (map[string]string, error) {
|
||||||
|
out := make(map[string]string, len(keys))
|
||||||
|
for start := 0; start < len(keys); start += mgetBatch {
|
||||||
|
end := min(start+mgetBatch, len(keys))
|
||||||
|
chunk := keys[start:end]
|
||||||
|
args := make([]any, len(chunk))
|
||||||
|
for i, k := range chunk {
|
||||||
|
args[i] = k
|
||||||
|
}
|
||||||
|
rows, err := q.QueryContext(ctx, `SELECT k, v FROM kv WHERE k IN (?`+strings.Repeat(",?", len(chunk)-1)+`)`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var k, v string
|
||||||
|
if err := rows.Scan(&k, &v); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rangeQuery returns keys with prefix, strictly after `after` (when non-empty),
|
||||||
|
// in key order, at most limit (<= 0 means all).
|
||||||
|
func rangeQuery(ctx context.Context, q queryer, prefix, after string, limit int) ([]KV, error) {
|
||||||
|
lo, loOp := prefix, ">="
|
||||||
|
if after != "" && after >= prefix {
|
||||||
|
lo, loOp = after, ">"
|
||||||
|
}
|
||||||
|
query := `SELECT k, v FROM kv WHERE k ` + loOp + ` ?`
|
||||||
|
args := []any{lo}
|
||||||
|
if end := PrefixEnd(prefix); end != "" {
|
||||||
|
query += ` AND k < ?`
|
||||||
|
args = append(args, end)
|
||||||
|
}
|
||||||
|
query += ` ORDER BY k`
|
||||||
|
if limit > 0 {
|
||||||
|
query += ` LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
}
|
||||||
|
rows, err := q.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []KV
|
||||||
|
for rows.Next() {
|
||||||
|
var kv KV
|
||||||
|
if err := rows.Scan(&kv.K, &kv.V); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, kv)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func count(ctx context.Context, q queryer, prefix string) (int64, error) {
|
||||||
|
query := `SELECT count(*) FROM kv WHERE k >= ?`
|
||||||
|
args := []any{prefix}
|
||||||
|
if end := PrefixEnd(prefix); end != "" {
|
||||||
|
query += ` AND k < ?`
|
||||||
|
args = append(args, end)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
err := q.QueryRowContext(ctx, query, args...).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the committed value of k.
|
||||||
|
func (s *Store) Get(ctx context.Context, k string) (string, bool, error) { return get(ctx, s.r, k) }
|
||||||
|
|
||||||
|
// MGet returns the committed values of the keys that exist.
|
||||||
|
func (s *Store) MGet(ctx context.Context, keys []string) (map[string]string, error) {
|
||||||
|
return mget(ctx, s.r, keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Range returns committed keys under prefix after `after`, in key order.
|
||||||
|
func (s *Store) Range(ctx context.Context, prefix, after string, limit int) ([]KV, error) {
|
||||||
|
return rangeQuery(ctx, s.r, prefix, after, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns how many committed keys sit under prefix.
|
||||||
|
func (s *Store) Count(ctx context.Context, prefix string) (int64, error) {
|
||||||
|
return count(ctx, s.r, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanPrefix walks every committed key under prefix in pages of `page`, each
|
||||||
|
// page its own short read so a long walk never pins a WAL snapshot (which
|
||||||
|
// would stop checkpoints and let the WAL grow without bound).
|
||||||
|
func (s *Store) ScanPrefix(ctx context.Context, prefix string, page int, fn func([]KV) error) error {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 5000
|
||||||
|
}
|
||||||
|
after := ""
|
||||||
|
for {
|
||||||
|
batch, err := rangeQuery(ctx, s.r, prefix, after, page)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(batch) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := fn(batch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(batch) < page {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
after = batch[len(batch)-1].K
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAll counts every row. A full scan — callers cache it.
|
||||||
|
func (s *Store) CountAll(ctx context.Context) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := s.r.QueryRowContext(ctx, `SELECT count(*) FROM kv`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrClosed is returned by Update on a closed store.
|
||||||
|
var ErrClosed = errors.New("kv: store closed")
|
||||||
|
|
||||||
|
// Update runs fn atomically: its writes (and the change events they publish)
|
||||||
|
// commit together or not at all. fn may share a SQLite transaction with other
|
||||||
|
// concurrent Updates (group commit) but is isolated from their failures by a
|
||||||
|
// savepoint. Never call Update from inside fn.
|
||||||
|
//
|
||||||
|
// ctx bounds only the wait for the writer. Once fn starts it runs to
|
||||||
|
// completion: interrupting a statement inside a shared transaction would roll
|
||||||
|
// back every request in the group.
|
||||||
|
func (s *Store) Update(ctx context.Context, fn func(tx *Tx) error) error {
|
||||||
|
req := &writeReq{fn: fn, done: make(chan error, 1)}
|
||||||
|
select {
|
||||||
|
case s.writes <- req:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-s.stopped:
|
||||||
|
return ErrClosed
|
||||||
|
}
|
||||||
|
return <-req.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// writer is the single goroutine that executes writes.
|
||||||
|
func (s *Store) writer() {
|
||||||
|
for {
|
||||||
|
var first *writeReq
|
||||||
|
select {
|
||||||
|
case first = <-s.writes:
|
||||||
|
case <-s.stopped:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
group := []*writeReq{first}
|
||||||
|
drain:
|
||||||
|
for len(group) < maxGroup {
|
||||||
|
select {
|
||||||
|
case r := <-s.writes:
|
||||||
|
group = append(group, r)
|
||||||
|
default:
|
||||||
|
break drain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.writeMu.Lock()
|
||||||
|
s.runGroup(group)
|
||||||
|
s.writeMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// group is the state shared by the requests of one commit.
|
||||||
|
type group struct {
|
||||||
|
tx *sql.Tx
|
||||||
|
pending map[string]*streamPending
|
||||||
|
stmts map[*sql.Stmt]*sql.Stmt
|
||||||
|
counters map[string]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *group) snapshot() map[string]streamPending {
|
||||||
|
out := make(map[string]streamPending, len(g.pending))
|
||||||
|
for k, p := range g.pending {
|
||||||
|
out[k] = *p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *group) restore(snap map[string]streamPending) {
|
||||||
|
g.pending = make(map[string]*streamPending, len(snap))
|
||||||
|
for k, p := range snap {
|
||||||
|
p := p
|
||||||
|
g.pending[k] = &p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) runGroup(reqs []*writeReq) {
|
||||||
|
ctx := context.Background()
|
||||||
|
results := make([]error, len(reqs))
|
||||||
|
fail := func(err error) {
|
||||||
|
for i, r := range reqs {
|
||||||
|
if results[i] == nil {
|
||||||
|
results[i] = err
|
||||||
|
}
|
||||||
|
r.done <- results[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlTx, err := s.w.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
fail(fmt.Errorf("begin: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g := &group{tx: sqlTx}
|
||||||
|
var commitHooks []func()
|
||||||
|
for i, r := range reqs {
|
||||||
|
if _, err := sqlTx.ExecContext(ctx, `SAVEPOINT req`); err != nil {
|
||||||
|
_ = sqlTx.Rollback()
|
||||||
|
fail(fmt.Errorf("savepoint: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snap := g.snapshot()
|
||||||
|
tx := &Tx{ctx: ctx, s: s, g: g}
|
||||||
|
err := r.fn(tx)
|
||||||
|
if err == nil {
|
||||||
|
err = tx.flushEvents()
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
g.mergeCounters(tx.counters)
|
||||||
|
commitHooks = append(commitHooks, tx.onCommit...)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
results[i] = err
|
||||||
|
if _, rbErr := sqlTx.ExecContext(ctx, `ROLLBACK TO req`); rbErr != nil {
|
||||||
|
_ = sqlTx.Rollback()
|
||||||
|
fail(fmt.Errorf("rollback to savepoint: %w (after %v)", rbErr, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.restore(snap)
|
||||||
|
}
|
||||||
|
if _, err := sqlTx.ExecContext(ctx, `RELEASE req`); err != nil {
|
||||||
|
_ = sqlTx.Rollback()
|
||||||
|
fail(fmt.Errorf("release savepoint: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := (&Tx{ctx: ctx, s: s, g: g}).flushCounters(); err != nil {
|
||||||
|
_ = sqlTx.Rollback()
|
||||||
|
fail(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := (&Tx{ctx: ctx, s: s, g: g}).flushHWM(); err != nil {
|
||||||
|
_ = sqlTx.Rollback()
|
||||||
|
fail(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := sqlTx.Commit(); err != nil {
|
||||||
|
fail(fmt.Errorf("commit: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.afterCommit(g.pending)
|
||||||
|
// Hooks run in the writer goroutine, in commit order, before any request
|
||||||
|
// returns: a caller that orders work by commit (meta-core's apply queue)
|
||||||
|
// sees it in exactly that order.
|
||||||
|
for _, h := range commitHooks {
|
||||||
|
h()
|
||||||
|
}
|
||||||
|
for i, r := range reqs {
|
||||||
|
r.done <- results[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tx is one Update's view of the write transaction. Its reads see its own
|
||||||
|
// writes (and those of earlier requests in the same group).
|
||||||
|
type Tx struct {
|
||||||
|
ctx context.Context
|
||||||
|
s *Store
|
||||||
|
g *group
|
||||||
|
events []map[string]string
|
||||||
|
counters map[string]int64
|
||||||
|
onCommit []func()
|
||||||
|
quiet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnCommit registers fn to run after the transaction commits, only if this
|
||||||
|
// request succeeded. Hooks run in the writer goroutine in commit order and
|
||||||
|
// must not block or call Update.
|
||||||
|
func (t *Tx) OnCommit(fn func()) { t.onCommit = append(t.onCommit, fn) }
|
||||||
|
|
||||||
|
// Quiet stops this request's changes from publishing events (a bulk rebuild
|
||||||
|
// whose consumers are told to take a snapshot instead). Counters and the
|
||||||
|
// OnChange hook still run.
|
||||||
|
func (t *Tx) Quiet() { t.quiet = true }
|
||||||
|
|
||||||
|
// Query runs a raw multi-row query in the transaction.
|
||||||
|
func (t *Tx) Query(query string, args ...any) (*sql.Rows, error) {
|
||||||
|
return t.sqlTx().QueryContext(t.ctx, query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tx) sqlTx() *sql.Tx { return t.g.tx }
|
||||||
|
|
||||||
|
// stmt binds one of the store's prepared statements to the transaction.
|
||||||
|
func (t *Tx) stmt(st *sql.Stmt) *sql.Stmt {
|
||||||
|
if t.g.stmts == nil {
|
||||||
|
t.g.stmts = make(map[*sql.Stmt]*sql.Stmt, 5)
|
||||||
|
}
|
||||||
|
bound := t.g.stmts[st]
|
||||||
|
if bound == nil {
|
||||||
|
bound = t.g.tx.StmtContext(t.ctx, st)
|
||||||
|
t.g.stmts[st] = bound
|
||||||
|
}
|
||||||
|
return bound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context is the context the transaction runs under.
|
||||||
|
func (t *Tx) Context() context.Context { return t.ctx }
|
||||||
|
|
||||||
|
func (t *Tx) Get(ctx context.Context, k string) (string, bool, error) {
|
||||||
|
var v string
|
||||||
|
err := t.stmt(t.s.stGet).QueryRowContext(ctx, k).Scan(&v)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return v, true, nil
|
||||||
|
}
|
||||||
|
func (t *Tx) MGet(ctx context.Context, keys []string) (map[string]string, error) {
|
||||||
|
return mget(ctx, t.sqlTx(), keys)
|
||||||
|
}
|
||||||
|
func (t *Tx) Range(ctx context.Context, prefix, after string, limit int) ([]KV, error) {
|
||||||
|
return rangeQuery(ctx, t.sqlTx(), prefix, after, limit)
|
||||||
|
}
|
||||||
|
func (t *Tx) Count(ctx context.Context, prefix string) (int64, error) {
|
||||||
|
return count(ctx, t.sqlTx(), prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exec runs a raw statement in the transaction — for the caller's derived
|
||||||
|
// tables (FTS), never for kv rows (which would skip events).
|
||||||
|
func (t *Tx) Exec(query string, args ...any) (sql.Result, error) {
|
||||||
|
return t.sqlTx().ExecContext(t.ctx, query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryRow runs a raw single-row query in the transaction.
|
||||||
|
func (t *Tx) QueryRow(query string, args ...any) *sql.Row {
|
||||||
|
return t.sqlTx().QueryRowContext(t.ctx, query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put sets k = v. A no-op — no row write, no index churn, no event — when k
|
||||||
|
// already holds v. (Redis keyspace notifications published `set` even for an
|
||||||
|
// unchanged value; with meta-core persisting the same gateway hits again on
|
||||||
|
// every repeated query, those no-op writes were most of the write load.)
|
||||||
|
func (t *Tx) Put(k, v string) error {
|
||||||
|
_, err := t.Set(k, v)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set is Put that reports whether the value changed.
|
||||||
|
func (t *Tx) Set(k, v string) (bool, error) {
|
||||||
|
old, had, err := t.Get(t.ctx, k)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("put %q: %w", k, err)
|
||||||
|
}
|
||||||
|
if had && old == v {
|
||||||
|
t.s.noops.Add(1)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if _, err := t.stmt(t.s.stPut).ExecContext(t.ctx, k, v); err != nil {
|
||||||
|
return false, fmt.Errorf("put %q: %w", k, err)
|
||||||
|
}
|
||||||
|
t.s.puts.Add(1)
|
||||||
|
t.changed("set", k, old, had, v, true)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutIfAbsent sets k = v only when k does not exist. Reports whether it wrote.
|
||||||
|
func (t *Tx) PutIfAbsent(k, v string) (bool, error) {
|
||||||
|
res, err := t.stmt(t.s.stPutIfAbsent).ExecContext(t.ctx, k, v)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("put %q: %w", k, err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n > 0 {
|
||||||
|
t.changed("set", k, "", false, v, true)
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes k. Reports whether it existed; publishes `del` only then,
|
||||||
|
// as Redis did.
|
||||||
|
func (t *Tx) Delete(k string) (bool, error) {
|
||||||
|
var old string
|
||||||
|
err := t.stmt(t.s.stDelete).QueryRowContext(t.ctx, k).Scan(&old)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("delete %q: %w", k, err)
|
||||||
|
}
|
||||||
|
t.changed("del", k, old, true, "", false)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePrefix removes up to limit keys under prefix (limit <= 0: all) and
|
||||||
|
// returns how many went. Bulk callers loop over separate Updates with a limit
|
||||||
|
// so one transaction never grows to millions of rows.
|
||||||
|
func (t *Tx) DeletePrefix(prefix string, limit int) (int64, error) {
|
||||||
|
end := PrefixEnd(prefix)
|
||||||
|
sel := `SELECT k FROM kv WHERE k >= ?`
|
||||||
|
args := []any{prefix}
|
||||||
|
if end != "" {
|
||||||
|
sel += ` AND k < ?`
|
||||||
|
args = append(args, end)
|
||||||
|
}
|
||||||
|
if limit > 0 {
|
||||||
|
sel += ` ORDER BY k LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
}
|
||||||
|
rows, err := t.sqlTx().QueryContext(t.ctx, `DELETE FROM kv WHERE k IN (`+sel+`) RETURNING k, v`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("delete prefix %q: %w", prefix, err)
|
||||||
|
}
|
||||||
|
var gone []KV
|
||||||
|
for rows.Next() {
|
||||||
|
var kv KV
|
||||||
|
if err := rows.Scan(&kv.K, &kv.V); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
gone = append(gone, kv)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, kv := range gone {
|
||||||
|
t.changed("del", kv.K, kv.V, true, "", false)
|
||||||
|
}
|
||||||
|
return int64(len(gone)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// changed records one real change: the event (when in scope) and the hook.
|
||||||
|
func (t *Tx) changed(op, k, old string, hadOld bool, v string, hasNew bool) {
|
||||||
|
t.event(op, k)
|
||||||
|
if t.s.opts.OnChange != nil {
|
||||||
|
t.s.opts.OnChange(t, k, old, hadOld, v, hasNew)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddCounter adds delta to the integer counter stored at key. Deltas are
|
||||||
|
// summed in memory and written once per commit (a hot counter touched by every
|
||||||
|
// record in a group costs one row write, not one per record); they are
|
||||||
|
// discarded with the request if it fails. A counter that reaches zero is
|
||||||
|
// deleted.
|
||||||
|
func (t *Tx) AddCounter(key string, delta int64) {
|
||||||
|
if delta == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.counters == nil {
|
||||||
|
t.counters = make(map[string]int64)
|
||||||
|
}
|
||||||
|
t.counters[key] += delta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *group) mergeCounters(c map[string]int64) {
|
||||||
|
if len(c) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if g.counters == nil {
|
||||||
|
g.counters = make(map[string]int64, len(c))
|
||||||
|
}
|
||||||
|
for k, d := range c {
|
||||||
|
g.counters[k] += d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushCounters applies the group's counter deltas, bypassing Put (counters
|
||||||
|
// are bookkeeping: no events, no hook).
|
||||||
|
func (t *Tx) flushCounters() error {
|
||||||
|
for k, d := range t.g.counters {
|
||||||
|
if d == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cur, _, err := t.Get(t.ctx, k)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := strconv.ParseInt(cur, 10, 64)
|
||||||
|
n += d
|
||||||
|
if n <= 0 {
|
||||||
|
if _, err := t.sqlTx().ExecContext(t.ctx, `DELETE FROM kv WHERE k = ?`, k); err != nil {
|
||||||
|
return fmt.Errorf("counter %s: %w", k, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := t.stmt(t.s.stPut).ExecContext(t.ctx, k, strconv.FormatInt(n, 10)); err != nil {
|
||||||
|
return fmt.Errorf("counter %s: %w", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter reads a committed counter (0 when absent).
|
||||||
|
func (s *Store) Counter(ctx context.Context, key string) (int64, bool, error) {
|
||||||
|
v, ok, err := s.Get(ctx, key)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return 0, ok, err
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
return n, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteStats reports how many Puts changed a value and how many found it
|
||||||
|
// already in place, since open.
|
||||||
|
func (s *Store) WriteStats() (changed, unchanged int64) {
|
||||||
|
return s.puts.Load(), s.noops.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tx) event(op, k string) {
|
||||||
|
if t.quiet || t.s.opts.EventStream == "" || t.s.opts.EventKey == nil || !t.s.opts.EventKey(k) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.events = append(t.events, map[string]string{
|
||||||
|
"type": op,
|
||||||
|
"key": k,
|
||||||
|
"ts": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushEvents appends the queued change events to the event stream, inside
|
||||||
|
// the transaction, right before commit.
|
||||||
|
func (t *Tx) flushEvents() error {
|
||||||
|
if len(t.events) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
evs := t.events
|
||||||
|
t.events = nil
|
||||||
|
for _, e := range evs {
|
||||||
|
if _, err := t.Append(t.s.opts.EventStream, t.s.opts.EventMaxLen, e); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
package kv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openTest(t *testing.T, path string) *Store {
|
||||||
|
t.Helper()
|
||||||
|
if path == "" {
|
||||||
|
path = filepath.Join(t.TempDir(), "kv.db")
|
||||||
|
}
|
||||||
|
s, err := Open(path, Options{
|
||||||
|
EventStream: "meta:events",
|
||||||
|
EventMaxLen: 100_000,
|
||||||
|
EventKey: func(k string) bool { return strings.HasPrefix(k, "file:") },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(t *testing.T, s *Store, fn func(tx *Tx) error) {
|
||||||
|
t.Helper()
|
||||||
|
if err := s.Update(context.Background(), fn); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func events(t *testing.T, s *Store) []Entry {
|
||||||
|
t.Helper()
|
||||||
|
es, err := s.ReadAfter(context.Background(), "meta:events", "0", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read events: %v", err)
|
||||||
|
}
|
||||||
|
return es
|
||||||
|
}
|
||||||
|
|
||||||
|
// The replacement for keyspace notifications: a change and its event commit
|
||||||
|
// together. `set` only for a real change (an unchanged value is a no-op: no
|
||||||
|
// write, no event), `del` only for a key that existed, nothing for keys outside
|
||||||
|
// the event scope.
|
||||||
|
func TestChangesPublishEventsWithRedisSemantics(t *testing.T) {
|
||||||
|
s := openTest(t, "")
|
||||||
|
update(t, s, func(tx *Tx) error {
|
||||||
|
_ = tx.Put("file:A/title", "x")
|
||||||
|
_ = tx.Put("file:A/title", "x") // unchanged: no write, no event
|
||||||
|
_ = tx.Put("cid:bafk", "A") // outside the event scope
|
||||||
|
_, _ = tx.Delete("file:A/missing")
|
||||||
|
_, _ = tx.Delete("file:A/title")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
got := events(t, s)
|
||||||
|
want := []string{"set file:A/title", "del file:A/title"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("events = %+v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for i, e := range got {
|
||||||
|
if e.Values["type"]+" "+e.Values["key"] != want[i] {
|
||||||
|
t.Errorf("event %d = %v, want %q", i, e.Values, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolledBackChangesPublishNothing(t *testing.T) {
|
||||||
|
s := openTest(t, "")
|
||||||
|
err := s.Update(context.Background(), func(tx *Tx) error {
|
||||||
|
_ = tx.Put("file:A/title", "x")
|
||||||
|
return errors.New("boom")
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want the error back")
|
||||||
|
}
|
||||||
|
if _, ok, _ := s.Get(context.Background(), "file:A/title"); ok {
|
||||||
|
t.Fatal("rolled-back write is visible")
|
||||||
|
}
|
||||||
|
if es := events(t, s); len(es) != 0 {
|
||||||
|
t.Fatalf("rolled-back write published %+v", es)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletePrefixPublishesOneDelPerKey(t *testing.T) {
|
||||||
|
s := openTest(t, "")
|
||||||
|
update(t, s, func(tx *Tx) error {
|
||||||
|
for _, k := range []string{"file:A/a", "file:A/b", "file:AB/c"} {
|
||||||
|
_ = tx.Put(k, "1")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
var n int64
|
||||||
|
update(t, s, func(tx *Tx) error {
|
||||||
|
var err error
|
||||||
|
n, err = tx.DeletePrefix("file:A/", 0)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("deleted %d, want 2 (file:AB/ is not under file:A/)", n)
|
||||||
|
}
|
||||||
|
es := events(t, s)
|
||||||
|
if dels := es[3:]; len(dels) != 2 || dels[0].Values["type"] != "del" || dels[1].Values["type"] != "del" {
|
||||||
|
t.Fatalf("want two del events, got %+v", es)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDs are Redis-shaped and strictly increasing, within one millisecond and
|
||||||
|
// across a reopen — the SSE cursors consumers persist depend on both.
|
||||||
|
func TestStreamIDsAreMonotonicAcrossReopen(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "kv.db")
|
||||||
|
s, err := Open(path, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
if err := s.Update(context.Background(), func(tx *Tx) error {
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
id, err := tx.Append("file:events", 0, map[string]string{"n": "x"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = s.Close()
|
||||||
|
|
||||||
|
s2 := openTest(t, path)
|
||||||
|
var after string
|
||||||
|
update(t, s2, func(tx *Tx) error {
|
||||||
|
var err error
|
||||||
|
after, err = tx.Append("file:events", 0, map[string]string{"n": "y"})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
ids = append(ids, after)
|
||||||
|
for i := 1; i < len(ids); i++ {
|
||||||
|
if !idLess(t, ids[i-1], ids[i]) {
|
||||||
|
t.Fatalf("id %d (%s) does not follow %s", i, ids[i], ids[i-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(ids[0], "-") || strings.HasPrefix(ids[0], "0") {
|
||||||
|
t.Fatalf("id %q is not the unpadded <ms>-<seq> form", ids[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func idLess(t *testing.T, a, b string) bool {
|
||||||
|
t.Helper()
|
||||||
|
am, as, err := ParseID(a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bm, bs, err := ParseID(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return am < bm || (am == bm && as < bs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The watcher's reset clears file:events. The next ID must still exceed every
|
||||||
|
// ID issued before the clear, or a consumer holding an old cursor would skip
|
||||||
|
// the reset event.
|
||||||
|
func TestClearStreamKeepsTheHighWaterMark(t *testing.T) {
|
||||||
|
s := openTest(t, "")
|
||||||
|
var before, after string
|
||||||
|
update(t, s, func(tx *Tx) error {
|
||||||
|
var err error
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if before, err = tx.Append("file:events", 0, map[string]string{"type": "add"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
update(t, s, func(tx *Tx) error { return tx.ClearStream("file:events") })
|
||||||
|
if n, _ := s.Len(context.Background(), "file:events"); n != 0 {
|
||||||
|
t.Fatalf("len after clear = %d", n)
|
||||||
|
}
|
||||||
|
update(t, s, func(tx *Tx) error {
|
||||||
|
var err error
|
||||||
|
after, err = tx.Append("file:events", 0, map[string]string{"type": "reset"})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if !idLess(t, before, after) {
|
||||||
|
t.Fatalf("post-clear id %s does not follow pre-clear %s", after, before)
|
||||||
|
}
|
||||||
|
got, err := s.ReadAfter(context.Background(), "file:events", before, 10)
|
||||||
|
if err != nil || len(got) != 1 || got[0].Values["type"] != "reset" {
|
||||||
|
t.Fatalf("an old cursor must see the reset: %+v err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAXLEN ~ semantics across many small transactions (the common case: one
|
||||||
|
// event per write).
|
||||||
|
func TestStreamTrimsAcrossTransactions(t *testing.T) {
|
||||||
|
defer func(old int64) { trimEvery = old }(trimEvery)
|
||||||
|
trimEvery = 100
|
||||||
|
s := openTest(t, "")
|
||||||
|
const maxLen = 50
|
||||||
|
for i := int64(0); i < 3*trimEvery; i++ {
|
||||||
|
update(t, s, func(tx *Tx) error {
|
||||||
|
_, err := tx.Append("x", maxLen, map[string]string{"i": "1"})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
n, err := s.Len(context.Background(), "x")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n > maxLen+trimEvery {
|
||||||
|
t.Fatalf("stream holds %d entries, want <= %d", n, maxLen+trimEvery)
|
||||||
|
}
|
||||||
|
oldest, ok, err := s.Oldest(context.Background(), "x")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if last, _ := s.LastID(context.Background(), "x"); !idLess(t, oldest.ID, last) {
|
||||||
|
t.Fatalf("oldest %s should precede last %s", oldest.ID, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A subscriber that took the wake channel before reading is woken by the next
|
||||||
|
// commit — the property the SSE pump relies on to not miss an append.
|
||||||
|
func TestWaitIsClosedByTheNextCommit(t *testing.T) {
|
||||||
|
s := openTest(t, "")
|
||||||
|
wake := s.Wait("meta:events")
|
||||||
|
update(t, s, func(tx *Tx) error { return tx.Put("file:A/x", "1") })
|
||||||
|
select {
|
||||||
|
case <-wake:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("commit did not wake the waiter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadAfterZeroFormsReadFromTheStart(t *testing.T) {
|
||||||
|
s := openTest(t, "")
|
||||||
|
update(t, s, func(tx *Tx) error {
|
||||||
|
_, err := tx.Append("x", 0, map[string]string{"n": "1"})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
for _, id := range []string{"", "0", "0-0"} {
|
||||||
|
got, err := s.ReadAfter(context.Background(), "x", id, 10)
|
||||||
|
if err != nil || len(got) != 1 {
|
||||||
|
t.Errorf("ReadAfter(%q) = %d entries, err %v; want 1", id, len(got), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRangeIsExactlyThePrefix(t *testing.T) {
|
||||||
|
s := openTest(t, "")
|
||||||
|
update(t, s, func(tx *Tx) error {
|
||||||
|
for _, k := range []string{"file:A/a", "file:A/b/c", "file:A0", "file:AB/x", "file:A"} {
|
||||||
|
_ = tx.Put(k, "v")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
rows, err := s.Range(context.Background(), "file:A/", "", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 || rows[0].K != "file:A/a" || rows[1].K != "file:A/b/c" {
|
||||||
|
t.Fatalf("range = %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent Updates share commits; a failing one must roll back alone and
|
||||||
|
// leave its neighbours' writes and events intact.
|
||||||
|
func TestGroupCommitIsolatesFailures(t *testing.T) {
|
||||||
|
s := openTest(t, "")
|
||||||
|
const n = 200
|
||||||
|
errs := make(chan error, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
go func(i int) {
|
||||||
|
errs <- s.Update(context.Background(), func(tx *Tx) error {
|
||||||
|
k := fmt.Sprintf("file:R%03d/title", i)
|
||||||
|
if err := tx.Put(k, "x"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if i%10 == 0 {
|
||||||
|
return errors.New("fail")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
failed := 0
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if <-errs != nil {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failed != n/10 {
|
||||||
|
t.Fatalf("failed = %d, want %d", failed, n/10)
|
||||||
|
}
|
||||||
|
rows, _ := s.Range(context.Background(), "file:", "", 0)
|
||||||
|
if len(rows) != n-n/10 {
|
||||||
|
t.Fatalf("committed %d rows, want %d", len(rows), n-n/10)
|
||||||
|
}
|
||||||
|
if es := events(t, s); len(es) != n-n/10 {
|
||||||
|
t.Fatalf("events = %d, want %d (one per committed write, none for failures)", len(es), n-n/10)
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
var i int
|
||||||
|
fmt.Sscanf(r.K, "file:R%03d/title", &i)
|
||||||
|
if i%10 == 0 {
|
||||||
|
t.Fatalf("failed request's write %s committed", r.K)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The change hook sees real changes only, with old and new values, and
|
||||||
|
// counters follow the request's fate (a failed request's deltas are dropped).
|
||||||
|
func TestOnChangeAndCounters(t *testing.T) {
|
||||||
|
type change struct{ k, old, new string }
|
||||||
|
var seen []change
|
||||||
|
s, err := Open(filepath.Join(t.TempDir(), "kv.db"), Options{
|
||||||
|
OnChange: func(tx *Tx, k, old string, hadOld bool, v string, hasNew bool) {
|
||||||
|
seen = append(seen, change{k, old, v})
|
||||||
|
if hasNew {
|
||||||
|
tx.AddCounter("count/"+v, 1)
|
||||||
|
}
|
||||||
|
if hadOld {
|
||||||
|
tx.AddCounter("count/"+old, -1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
update(t, s, func(tx *Tx) error {
|
||||||
|
_ = tx.Put("a", "x")
|
||||||
|
_ = tx.Put("b", "x")
|
||||||
|
_ = tx.Put("a", "x") // no-op
|
||||||
|
_ = tx.Put("a", "y")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
_ = s.Update(context.Background(), func(tx *Tx) error {
|
||||||
|
_ = tx.Put("c", "x")
|
||||||
|
return errors.New("fail")
|
||||||
|
})
|
||||||
|
update(t, s, func(tx *Tx) error { _, err := tx.DeletePrefix("b", 0); return err })
|
||||||
|
|
||||||
|
if len(seen) != 5 { // a=x, b=x, a x→y, c=x (rolled back, hook still ran), del b; stats count attempts
|
||||||
|
t.Fatalf("changes = %+v", seen)
|
||||||
|
}
|
||||||
|
for k, want := range map[string]int64{"count/x": 0, "count/y": 1} {
|
||||||
|
got, _, err := s.Counter(context.Background(), k)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Errorf("%s = %d (err %v), want %d", k, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed, unchanged := s.WriteStats(); changed != 4 || unchanged != 1 {
|
||||||
|
t.Errorf("write stats = %d changed / %d unchanged", changed, unchanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
package kv
|
||||||
|
|
||||||
|
// Append-only event streams, as rows of the kv table.
|
||||||
|
//
|
||||||
|
// A stream entry is the row
|
||||||
|
//
|
||||||
|
// ev:<stream>/<ms:13>-<seq:10> → JSON object of the entry's string fields
|
||||||
|
//
|
||||||
|
// Zero padding makes key order equal ID order, so reading "after an ID" is a
|
||||||
|
// plain range read and trimming is a range delete. On the wire IDs are the
|
||||||
|
// unpadded Redis form (`1700000000000-0`): meta-sort, meta-share and
|
||||||
|
// meta-stremio persist SSE cursors to disk across the Redis → SQLite switch,
|
||||||
|
// and those compare as <ms>-<seq> pairs.
|
||||||
|
//
|
||||||
|
// The last issued ID is kept in `evhwm:<stream>` and updated in the same
|
||||||
|
// transaction as the append. That high-water mark, not the newest surviving
|
||||||
|
// row, seeds the next ID — so Clear (the watcher's `reset`) never reissues an
|
||||||
|
// ID an old cursor would treat as already seen.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is one stream entry.
|
||||||
|
type Entry struct {
|
||||||
|
ID string
|
||||||
|
Values map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamID struct{ ms, seq int64 }
|
||||||
|
|
||||||
|
func (id streamID) String() string {
|
||||||
|
return strconv.FormatInt(id.ms, 10) + "-" + strconv.FormatInt(id.seq, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id streamID) padded() string { return fmt.Sprintf("%013d-%010d", id.ms, id.seq) }
|
||||||
|
|
||||||
|
func (id streamID) less(o streamID) bool {
|
||||||
|
return id.ms < o.ms || (id.ms == o.ms && id.seq < o.seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseID parses a Redis-style stream ID. "", "0", "0-0", "-" all parse as the
|
||||||
|
// zero ID (before everything). A bare "<ms>" means "<ms>-0".
|
||||||
|
func ParseID(s string) (ms, seq int64, err error) {
|
||||||
|
if s == "" || s == "-" || s == "0" {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
msPart, seqPart, hasSeq := strings.Cut(s, "-")
|
||||||
|
ms, err = strconv.ParseInt(msPart, 10, 64)
|
||||||
|
if err != nil || ms < 0 {
|
||||||
|
return 0, 0, fmt.Errorf("invalid stream id %q", s)
|
||||||
|
}
|
||||||
|
if hasSeq {
|
||||||
|
seq, err = strconv.ParseInt(seqPart, 10, 64)
|
||||||
|
if err != nil || seq < 0 {
|
||||||
|
return 0, 0, fmt.Errorf("invalid stream id %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ms, seq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamPrefix(stream string) string { return "ev:" + stream + "/" }
|
||||||
|
func hwmKey(stream string) string { return "evhwm:" + stream }
|
||||||
|
|
||||||
|
// streamState is the in-memory view of a stream's high-water mark plus the
|
||||||
|
// broadcast channel subscribers wait on. Updated only after a commit.
|
||||||
|
type streamState struct {
|
||||||
|
loaded bool
|
||||||
|
hwm streamID
|
||||||
|
sinceTrim int64 // committed appends since the last trim
|
||||||
|
notify chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamPending is what a transaction did to one stream, applied to
|
||||||
|
// streamState on commit (and discarded on rollback).
|
||||||
|
type streamPending struct {
|
||||||
|
hwm streamID
|
||||||
|
appends int64
|
||||||
|
trimmed bool
|
||||||
|
lastTrimAt int64 // value of appends when this transaction last trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) state(stream string) *streamState {
|
||||||
|
st := s.streams[stream]
|
||||||
|
if st == nil {
|
||||||
|
st = &streamState{notify: make(chan struct{})}
|
||||||
|
s.streams[stream] = st
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait returns a channel closed by the next commit that appends to stream.
|
||||||
|
// Take it BEFORE reading, so an append between the read and the wait is not
|
||||||
|
// missed.
|
||||||
|
func (s *Store) Wait(stream string) <-chan struct{} {
|
||||||
|
s.streamMu.Lock()
|
||||||
|
defer s.streamMu.Unlock()
|
||||||
|
return s.state(stream).notify
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) afterCommit(pending map[string]*streamPending) {
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.streamMu.Lock()
|
||||||
|
defer s.streamMu.Unlock()
|
||||||
|
for name, p := range pending {
|
||||||
|
st := s.state(name)
|
||||||
|
st.loaded = true
|
||||||
|
st.hwm = p.hwm
|
||||||
|
if p.trimmed {
|
||||||
|
st.sinceTrim = p.appends - p.lastTrimAt
|
||||||
|
} else {
|
||||||
|
st.sinceTrim += p.appends
|
||||||
|
}
|
||||||
|
close(st.notify)
|
||||||
|
st.notify = make(chan struct{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hwm returns the stream's last issued ID as seen by this transaction.
|
||||||
|
func (t *Tx) hwm(stream string) (streamID, error) {
|
||||||
|
if p := t.g.pending[stream]; p != nil {
|
||||||
|
return p.hwm, nil
|
||||||
|
}
|
||||||
|
t.s.streamMu.Lock()
|
||||||
|
st := t.s.state(stream)
|
||||||
|
loaded, hwm := st.loaded, st.hwm
|
||||||
|
t.s.streamMu.Unlock()
|
||||||
|
if loaded {
|
||||||
|
return hwm, nil
|
||||||
|
}
|
||||||
|
v, ok, err := t.Get(t.ctx, hwmKey(stream))
|
||||||
|
if err != nil {
|
||||||
|
return streamID{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return streamID{}, nil
|
||||||
|
}
|
||||||
|
ms, seq, err := ParseID(v)
|
||||||
|
if err != nil {
|
||||||
|
return streamID{}, err
|
||||||
|
}
|
||||||
|
return streamID{ms, seq}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append adds an entry to stream and returns its ID. The stream is trimmed to
|
||||||
|
// about maxLen entries (maxLen <= 0: unbounded).
|
||||||
|
func (t *Tx) Append(stream string, maxLen int64, fields map[string]string) (string, error) {
|
||||||
|
last, err := t.hwm(stream)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stream %s hwm: %w", stream, err)
|
||||||
|
}
|
||||||
|
id := streamID{ms: time.Now().UnixMilli()}
|
||||||
|
if !last.less(id) {
|
||||||
|
id = streamID{ms: last.ms, seq: last.seq + 1}
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(fields)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := t.stmt(t.s.stInsert).ExecContext(t.ctx, streamPrefix(stream)+id.padded(), string(body)); err != nil {
|
||||||
|
return "", fmt.Errorf("append %s: %w", stream, err)
|
||||||
|
}
|
||||||
|
if t.g.pending == nil {
|
||||||
|
t.g.pending = make(map[string]*streamPending)
|
||||||
|
}
|
||||||
|
p := t.g.pending[stream]
|
||||||
|
if p == nil {
|
||||||
|
p = &streamPending{}
|
||||||
|
t.g.pending[stream] = p
|
||||||
|
}
|
||||||
|
p.hwm = id
|
||||||
|
p.appends++
|
||||||
|
// Trim in steps, not per append: MAXLEN ~ semantics, like the XADD it
|
||||||
|
// replaces. The count spans transactions (most append one event each).
|
||||||
|
if maxLen > 0 {
|
||||||
|
t.s.streamMu.Lock()
|
||||||
|
since := t.s.state(stream).sinceTrim
|
||||||
|
t.s.streamMu.Unlock()
|
||||||
|
if !p.trimmed {
|
||||||
|
since += p.appends
|
||||||
|
} else {
|
||||||
|
since = p.appends - p.lastTrimAt
|
||||||
|
}
|
||||||
|
if since >= trimEvery {
|
||||||
|
if err := t.trim(stream, maxLen); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
p.trimmed, p.lastTrimAt = true, p.appends
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimEvery is how many appends pass between trims. A trim locates its cut
|
||||||
|
// point by walking maxLen entries back from the newest, so it is kept rare;
|
||||||
|
// the cost is overshooting maxLen by at most trimEvery entries.
|
||||||
|
var trimEvery int64 = 10_000 // var so tests can lower it
|
||||||
|
|
||||||
|
func (t *Tx) trim(stream string, maxLen int64) error {
|
||||||
|
prefix := streamPrefix(stream)
|
||||||
|
var cut string
|
||||||
|
err := t.sqlTx().QueryRowContext(t.ctx,
|
||||||
|
`SELECT k FROM kv WHERE k >= ? AND k < ? ORDER BY k DESC LIMIT 1 OFFSET ?`,
|
||||||
|
prefix, PrefixEnd(prefix), maxLen).Scan(&cut)
|
||||||
|
if err != nil {
|
||||||
|
return nil // fewer than maxLen entries (sql.ErrNoRows): nothing to trim
|
||||||
|
}
|
||||||
|
_, err = t.sqlTx().ExecContext(t.ctx, `DELETE FROM kv WHERE k >= ? AND k <= ?`, prefix, cut)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushHWM persists each touched stream's high-water mark — once per
|
||||||
|
// transaction, not per append.
|
||||||
|
func (t *Tx) flushHWM() error {
|
||||||
|
for stream, p := range t.g.pending {
|
||||||
|
if _, err := t.stmt(t.s.stPut).ExecContext(t.ctx, hwmKey(stream), p.hwm.String()); err != nil {
|
||||||
|
return fmt.Errorf("stream %s hwm: %w", stream, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrimAll trims stream to maxLen inside the transaction regardless of how many
|
||||||
|
// appends this transaction made. Used after an append burst split over many
|
||||||
|
// short transactions.
|
||||||
|
func (t *Tx) TrimAll(stream string, maxLen int64) error { return t.trim(stream, maxLen) }
|
||||||
|
|
||||||
|
// ClearStream deletes every entry of stream, keeping its high-water mark.
|
||||||
|
func (t *Tx) ClearStream(stream string) error {
|
||||||
|
prefix := streamPrefix(stream)
|
||||||
|
_, err := t.sqlTx().ExecContext(t.ctx, `DELETE FROM kv WHERE k >= ? AND k < ?`, prefix, PrefixEnd(prefix))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeEntry(stream, k, v string) (Entry, error) {
|
||||||
|
padded := strings.TrimPrefix(k, streamPrefix(stream))
|
||||||
|
ms, seq, err := ParseID(padded)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
var values map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(v), &values); err != nil {
|
||||||
|
return Entry{}, fmt.Errorf("decode %s: %w", k, err)
|
||||||
|
}
|
||||||
|
return Entry{ID: streamID{ms, seq}.String(), Values: values}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeEntries(stream string, rows []KV) ([]Entry, error) {
|
||||||
|
out := make([]Entry, 0, len(rows))
|
||||||
|
for _, kv := range rows {
|
||||||
|
e, err := decodeEntry(stream, kv.K, kv.V)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAfter returns up to n entries strictly after id, oldest first. "0",
|
||||||
|
// "0-0" and "" read from the beginning.
|
||||||
|
func (s *Store) ReadAfter(ctx context.Context, stream, id string, n int) ([]Entry, error) {
|
||||||
|
ms, seq, err := ParseID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prefix := streamPrefix(stream)
|
||||||
|
after := ""
|
||||||
|
if ms != 0 || seq != 0 {
|
||||||
|
after = prefix + streamID{ms, seq}.padded()
|
||||||
|
}
|
||||||
|
rows, err := rangeQuery(ctx, s.r, prefix, after, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeEntries(stream, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest returns the oldest surviving entry, or ok=false for an empty stream.
|
||||||
|
func (s *Store) Oldest(ctx context.Context, stream string) (Entry, bool, error) {
|
||||||
|
rows, err := rangeQuery(ctx, s.r, streamPrefix(stream), "", 1)
|
||||||
|
if err != nil || len(rows) == 0 {
|
||||||
|
return Entry{}, false, err
|
||||||
|
}
|
||||||
|
e, err := decodeEntry(stream, rows[0].K, rows[0].V)
|
||||||
|
return e, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastID returns the last ID ever issued on stream ("0-0" if none) — the
|
||||||
|
// cursor meaning "only entries appended from now on".
|
||||||
|
func (s *Store) LastID(ctx context.Context, stream string) (string, error) {
|
||||||
|
s.streamMu.Lock()
|
||||||
|
st := s.state(stream)
|
||||||
|
loaded, hwm := st.loaded, st.hwm
|
||||||
|
s.streamMu.Unlock()
|
||||||
|
if loaded {
|
||||||
|
return hwm.String(), nil
|
||||||
|
}
|
||||||
|
v, ok, err := s.Get(ctx, hwmKey(stream))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "0-0", nil
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len is the number of surviving entries.
|
||||||
|
func (s *Store) Len(ctx context.Context, stream string) (int64, error) {
|
||||||
|
return count(ctx, s.r, streamPrefix(stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevRange returns up to n entries newest first, stopping at (excluding)
|
||||||
|
// entries at or before afterID when afterID is non-empty.
|
||||||
|
func (s *Store) RevRange(ctx context.Context, stream, afterID string, n int) ([]Entry, error) {
|
||||||
|
prefix := streamPrefix(stream)
|
||||||
|
query := `SELECT k, v FROM kv WHERE k > ? AND k < ? ORDER BY k DESC`
|
||||||
|
lo := prefix
|
||||||
|
if afterID != "" {
|
||||||
|
ms, seq, err := ParseID(afterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lo = prefix + streamID{ms, seq}.padded()
|
||||||
|
}
|
||||||
|
args := []any{lo, PrefixEnd(prefix)}
|
||||||
|
if n > 0 {
|
||||||
|
query += ` LIMIT ?`
|
||||||
|
args = append(args, n)
|
||||||
|
}
|
||||||
|
rows, err := s.r.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var kvs []KV
|
||||||
|
for rows.Next() {
|
||||||
|
var kv KV
|
||||||
|
if err := rows.Scan(&kv.K, &kv.V); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
kvs = append(kvs, kv)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeEntries(stream, kvs)
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
type LeaderLockInfo struct {
|
type LeaderLockInfo struct {
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
BaseUrl string `json:"baseUrl"`
|
BaseUrl string `json:"baseUrl"`
|
||||||
ApiUrl string `json:"apiUrl"` // meta-core API (port 9000)
|
ApiUrl string `json:"apiUrl"` // meta-core API (port 9000)
|
||||||
RedisUrl string `json:"redisUrl"`
|
RedisUrl string `json:"redisUrl"`
|
||||||
WebdavUrl string `json:"webdavUrl"` // External WebDAV URL (via baseUrl)
|
WebdavUrl string `json:"webdavUrl"` // External WebDAV URL (via baseUrl)
|
||||||
WebdavUrlInternal string `json:"webdavUrlInternal"` // Internal WebDAV URL (direct to port 9000)
|
WebdavUrlInternal string `json:"webdavUrlInternal"` // Internal WebDAV URL (direct to port 9000)
|
||||||
@@ -72,9 +72,9 @@ func (p *LeaderInfoProvider) buildLeaderInfo() *LeaderLockInfo {
|
|||||||
webdavUrlInternal := fmt.Sprintf("http://%s:%d/webdav", hostname, p.config.HTTPPort)
|
webdavUrlInternal := fmt.Sprintf("http://%s:%d/webdav", hostname, p.config.HTTPPort)
|
||||||
|
|
||||||
return &LeaderLockInfo{
|
return &LeaderLockInfo{
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
BaseUrl: baseUrl,
|
BaseUrl: baseUrl,
|
||||||
ApiUrl: fmt.Sprintf("http://%s:%d", ip, p.config.HTTPPort),
|
ApiUrl: fmt.Sprintf("http://%s:%d", ip, p.config.HTTPPort),
|
||||||
// RedisUrl deliberately empty — api-mediated-access PR D removes
|
// RedisUrl deliberately empty — api-mediated-access PR D removes
|
||||||
// direct Redis exposure. Consumers route metadata I/O through
|
// direct Redis exposure. Consumers route metadata I/O through
|
||||||
// meta-core's HTTP API (/meta/{hash}*) and event streams through
|
// meta-core's HTTP API (/meta/{hash}*) and event streams through
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
// (crc32: "bafk3eaqerjhiwhy").
|
// (crc32: "bafk3eaqerjhiwhy").
|
||||||
var cidRegex = regexp.MustCompile(`^(ba[a-z2-7]{14,}|Qm[1-9A-HJ-NP-Za-km-z]{44})$`)
|
var cidRegex = regexp.MustCompile(`^(ba[a-z2-7]{14,}|Qm[1-9A-HJ-NP-Za-km-z]{44})$`)
|
||||||
|
|
||||||
// ClassifyValue returns the (primitive, hint) for a Redis value.
|
// ClassifyValue returns the (primitive, hint) for a stored value.
|
||||||
// exists=false (key missing) and JSON null literal both map to PrimUndefined.
|
// exists=false (key missing) and JSON null literal both map to PrimUndefined.
|
||||||
// Empty string is a valid string.
|
// Empty string is a valid string.
|
||||||
func ClassifyValue(value string, exists bool) (Primitive, Hint) {
|
func ClassifyValue(value string, exists bool) (Primitive, Hint) {
|
||||||
|
|||||||
+78
-236
@@ -2,21 +2,35 @@ package schema
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/metazla/meta-core/internal/events"
|
"github.com/metazla/meta-core/internal/kv"
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var langCodeRegex = regexp.MustCompile(`^[a-z]{2,3}$`)
|
var langCodeRegex = regexp.MustCompile(`^[a-z]{2,3}$`)
|
||||||
|
|
||||||
// fieldState is the per-field accumulator. perKey lets us undo a previous
|
// CounterPrefix is where the schema lives: one counter per (field, key hint,
|
||||||
// contribution when the same Redis key is re-SET or DELed.
|
// primitive, hint), maintained by the storage write path in the same
|
||||||
|
// transaction as the change (storage's OnChange hook → CounterKey).
|
||||||
|
const CounterPrefix = "idx:schema/"
|
||||||
|
|
||||||
|
const counterSep = "\x1f"
|
||||||
|
|
||||||
|
// CounterKey is the counter a record key holding value contributes to, or
|
||||||
|
// ok=false for keys that are not record properties.
|
||||||
|
func CounterKey(recordKey, value string) (string, bool) {
|
||||||
|
fieldPath, keyHint, ok := parseFieldPath(recordKey)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
prim, hint := ClassifyValue(value, true)
|
||||||
|
return CounterPrefix + fieldPath + counterSep + string(keyHint) + counterSep + string(prim) + counterSep + string(hint), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldState is the per-field accumulator.
|
||||||
type fieldState struct {
|
type fieldState struct {
|
||||||
counts map[string]int
|
counts map[string]int
|
||||||
primitives map[Primitive]int
|
primitives map[Primitive]int
|
||||||
@@ -24,12 +38,6 @@ type fieldState struct {
|
|||||||
typedCount int
|
typedCount int
|
||||||
undefinedCount int
|
undefinedCount int
|
||||||
languageKeyed bool
|
languageKeyed bool
|
||||||
perKey map[string]labelPair
|
|
||||||
}
|
|
||||||
|
|
||||||
type labelPair struct {
|
|
||||||
prim Primitive
|
|
||||||
hint Hint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFieldState() *fieldState {
|
func newFieldState() *fieldState {
|
||||||
@@ -37,160 +45,72 @@ func newFieldState() *fieldState {
|
|||||||
counts: make(map[string]int),
|
counts: make(map[string]int),
|
||||||
primitives: make(map[Primitive]int),
|
primitives: make(map[Primitive]int),
|
||||||
hintCounts: make(map[Hint]int),
|
hintCounts: make(map[Hint]int),
|
||||||
perKey: make(map[string]labelPair),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Indexer maintains a live field-schema by consuming meta:events.
|
// Indexer serves the per-field schema.
|
||||||
|
//
|
||||||
|
// The schema is a set of exact counters the write path keeps current in the
|
||||||
|
// same transaction as each change (see CounterKey), so serving it is one range
|
||||||
|
// read and nothing runs in the background. Two earlier shapes were dropped: a
|
||||||
|
// consumer of meta:events that remembered every record key it had seen (~2 GB
|
||||||
|
// at 6.3M keys), then a full rescan of the store every 10 minutes (~80 s of
|
||||||
|
// CPU each time, whether or not anyone looked).
|
||||||
type Indexer struct {
|
type Indexer struct {
|
||||||
client *redis.Client
|
store *kv.Store
|
||||||
keyPrefix string
|
|
||||||
|
|
||||||
mu sync.RWMutex
|
|
||||||
fields map[string]*fieldState
|
|
||||||
|
|
||||||
processed uint64 // total events processed since start
|
|
||||||
|
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
|
||||||
wg sync.WaitGroup
|
|
||||||
runMu sync.Mutex
|
|
||||||
running bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIndexer creates an Indexer. keyPrefix should match storage.Client.GetPrefix()
|
// NewIndexer creates an Indexer over store.
|
||||||
// so the indexer can build absolute Redis keys for GET calls.
|
func NewIndexer(store *kv.Store) *Indexer {
|
||||||
func NewIndexer(client *redis.Client, keyPrefix string) *Indexer {
|
return &Indexer{store: store}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
return &Indexer{
|
|
||||||
client: client,
|
|
||||||
keyPrefix: keyPrefix,
|
|
||||||
fields: make(map[string]*fieldState),
|
|
||||||
ctx: ctx,
|
|
||||||
cancel: cancel,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start begins consuming the meta:events stream from the earliest available ID.
|
// Start and Stop exist for the server's lifecycle; there is nothing to run.
|
||||||
func (i *Indexer) Start() error {
|
func (i *Indexer) Start() error { return nil }
|
||||||
i.runMu.Lock()
|
func (i *Indexer) Stop() error { return nil }
|
||||||
if i.running {
|
|
||||||
i.runMu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
i.running = true
|
|
||||||
i.runMu.Unlock()
|
|
||||||
|
|
||||||
i.wg.Add(1)
|
// Snapshot reads the counters and folds them into the per-field schema.
|
||||||
go i.consumeLoop()
|
func (i *Indexer) Snapshot(ctx context.Context) (*SchemaResponse, error) {
|
||||||
|
fields := make(map[string]*fieldState)
|
||||||
log.Println("[SchemaIndexer] Started - consuming meta:events stream")
|
err := i.store.ScanPrefix(ctx, CounterPrefix, 5000, func(rows []kv.KV) error {
|
||||||
return nil
|
for _, row := range rows {
|
||||||
}
|
parts := strings.Split(row.K[len(CounterPrefix):], counterSep)
|
||||||
|
if len(parts) != 4 {
|
||||||
// Stop terminates the consumer goroutine.
|
|
||||||
func (i *Indexer) Stop() error {
|
|
||||||
i.runMu.Lock()
|
|
||||||
if !i.running {
|
|
||||||
i.runMu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
i.running = false
|
|
||||||
i.runMu.Unlock()
|
|
||||||
|
|
||||||
i.cancel()
|
|
||||||
i.wg.Wait()
|
|
||||||
log.Println("[SchemaIndexer] Stopped")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProcessedCount returns the total number of stream events processed.
|
|
||||||
// Used by WaitForDrain to detect when a rescan flood has been absorbed.
|
|
||||||
func (i *Indexer) ProcessedCount() uint64 {
|
|
||||||
return atomic.LoadUint64(&i.processed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WaitForDrain blocks until ProcessedCount has been stable for `stableFor` or
|
|
||||||
// the timeout elapses. Suitable for use right after RepublishMetadata.
|
|
||||||
func (i *Indexer) WaitForDrain(timeout time.Duration) {
|
|
||||||
deadline := time.Now().Add(timeout)
|
|
||||||
last := i.ProcessedCount()
|
|
||||||
stableSince := time.Now()
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
now := i.ProcessedCount()
|
|
||||||
if now != last {
|
|
||||||
last = now
|
|
||||||
stableSince = time.Now()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if time.Since(stableSince) >= 300*time.Millisecond {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *Indexer) consumeLoop() {
|
|
||||||
defer i.wg.Done()
|
|
||||||
lastID := "0"
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-i.ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := i.client.XRead(i.ctx, &redis.XReadArgs{
|
|
||||||
Streams: []string{events.MetaEventsStream, lastID},
|
|
||||||
Block: 2 * time.Second,
|
|
||||||
Count: 500,
|
|
||||||
}).Result()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err == redis.Nil || i.ctx.Err() != nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Printf("[SchemaIndexer] XRead error: %v", err)
|
n, err := strconv.Atoi(row.V)
|
||||||
time.Sleep(1 * time.Second)
|
if err != nil || n <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
|
||||||
|
|
||||||
for _, stream := range res {
|
|
||||||
for _, msg := range stream.Messages {
|
|
||||||
lastID = msg.ID
|
|
||||||
i.processEvent(msg.Values)
|
|
||||||
atomic.AddUint64(&i.processed, 1)
|
|
||||||
}
|
}
|
||||||
|
fs := fields[parts[0]]
|
||||||
|
if fs == nil {
|
||||||
|
fs = newFieldState()
|
||||||
|
fields[parts[0]] = fs
|
||||||
|
}
|
||||||
|
if parts[1] != "" {
|
||||||
|
fs.languageKeyed = true
|
||||||
|
}
|
||||||
|
incContribution(fs, labelPair{prim: Primitive(parts[2]), hint: Hint(parts[3])}, n)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
snap := &SchemaResponse{
|
||||||
|
Fields: make(map[string]*FieldSchema, len(fields)),
|
||||||
|
GeneratedAt: time.Now().UTC(),
|
||||||
|
Source: "live",
|
||||||
|
}
|
||||||
|
for fp, fs := range fields {
|
||||||
|
snap.Fields[fp] = computeFieldSchema(fs)
|
||||||
|
}
|
||||||
|
return snap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Indexer) processEvent(values map[string]any) {
|
type labelPair struct {
|
||||||
op, _ := values["type"].(string)
|
prim Primitive
|
||||||
key, _ := values["key"].(string)
|
hint Hint
|
||||||
if key == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fieldPath, keyHint, ok := parseFieldPath(key)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch op {
|
|
||||||
case "set":
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
val, err := i.client.Get(ctx, i.keyPrefix+key).Result()
|
|
||||||
cancel()
|
|
||||||
exists := err != redis.Nil
|
|
||||||
if err != nil && err != redis.Nil {
|
|
||||||
// Treat transient errors as "value disappeared" — nothing to record.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prim, hint := ClassifyValue(val, exists)
|
|
||||||
i.upsert(fieldPath, key, keyHint, prim, hint)
|
|
||||||
case "del", "expire":
|
|
||||||
i.remove(fieldPath, key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseFieldPath turns "file:<rootID>/<propertyPath>" into the logical field
|
// parseFieldPath turns "file:<rootID>/<propertyPath>" into the logical field
|
||||||
@@ -222,99 +142,21 @@ func parseFieldPath(key string) (string, KeyHint, bool) {
|
|||||||
return property, "", true
|
return property, "", true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Indexer) upsert(fieldPath, redisKey string, keyHint KeyHint, prim Primitive, hint Hint) {
|
// incContribution adds n values of one (primitive, hint) to a field.
|
||||||
i.mu.Lock()
|
func incContribution(fs *fieldState, lp labelPair, n int) {
|
||||||
defer i.mu.Unlock()
|
|
||||||
|
|
||||||
fs, ok := i.fields[fieldPath]
|
|
||||||
if !ok {
|
|
||||||
fs = newFieldState()
|
|
||||||
i.fields[fieldPath] = fs
|
|
||||||
}
|
|
||||||
if keyHint != "" {
|
|
||||||
fs.languageKeyed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if old, ok := fs.perKey[redisKey]; ok {
|
|
||||||
decContribution(fs, old)
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.perKey[redisKey] = labelPair{prim: prim, hint: hint}
|
|
||||||
incContribution(fs, labelPair{prim: prim, hint: hint})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *Indexer) remove(fieldPath, redisKey string) {
|
|
||||||
i.mu.Lock()
|
|
||||||
defer i.mu.Unlock()
|
|
||||||
|
|
||||||
fs, ok := i.fields[fieldPath]
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
old, ok := fs.perKey[redisKey]
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delete(fs.perKey, redisKey)
|
|
||||||
decContribution(fs, old)
|
|
||||||
if len(fs.perKey) == 0 {
|
|
||||||
delete(i.fields, fieldPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func incContribution(fs *fieldState, lp labelPair) {
|
|
||||||
label := breakdownLabel(lp.prim, lp.hint)
|
label := breakdownLabel(lp.prim, lp.hint)
|
||||||
fs.counts[label]++
|
fs.counts[label] += n
|
||||||
if lp.prim == PrimUndefined {
|
if lp.prim == PrimUndefined {
|
||||||
fs.undefinedCount++
|
fs.undefinedCount += n
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fs.typedCount++
|
fs.typedCount += n
|
||||||
fs.primitives[lp.prim]++
|
fs.primitives[lp.prim] += n
|
||||||
if lp.hint != "" {
|
if lp.hint != "" {
|
||||||
fs.hintCounts[lp.hint]++
|
fs.hintCounts[lp.hint] += n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decContribution(fs *fieldState, lp labelPair) {
|
|
||||||
label := breakdownLabel(lp.prim, lp.hint)
|
|
||||||
fs.counts[label]--
|
|
||||||
if fs.counts[label] <= 0 {
|
|
||||||
delete(fs.counts, label)
|
|
||||||
}
|
|
||||||
if lp.prim == PrimUndefined {
|
|
||||||
fs.undefinedCount--
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fs.typedCount--
|
|
||||||
fs.primitives[lp.prim]--
|
|
||||||
if fs.primitives[lp.prim] <= 0 {
|
|
||||||
delete(fs.primitives, lp.prim)
|
|
||||||
}
|
|
||||||
if lp.hint != "" {
|
|
||||||
fs.hintCounts[lp.hint]--
|
|
||||||
if fs.hintCounts[lp.hint] <= 0 {
|
|
||||||
delete(fs.hintCounts, lp.hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot returns the current schema. Safe to call concurrently with consumption.
|
|
||||||
func (i *Indexer) Snapshot() *SchemaResponse {
|
|
||||||
i.mu.RLock()
|
|
||||||
defer i.mu.RUnlock()
|
|
||||||
|
|
||||||
out := &SchemaResponse{
|
|
||||||
Fields: make(map[string]*FieldSchema, len(i.fields)),
|
|
||||||
GeneratedAt: time.Now().UTC(),
|
|
||||||
Source: "live",
|
|
||||||
}
|
|
||||||
for fp, fs := range i.fields {
|
|
||||||
out.Fields[fp] = computeFieldSchema(fs)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func computeFieldSchema(fs *fieldState) *FieldSchema {
|
func computeFieldSchema(fs *fieldState) *FieldSchema {
|
||||||
out := &FieldSchema{
|
out := &FieldSchema{
|
||||||
Breakdown: make(map[string]int, len(fs.counts)),
|
Breakdown: make(map[string]int, len(fs.counts)),
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
package snapshot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PluginOutputArchivePrefix is where plugin-output files live inside the ZIP.
|
|
||||||
// Trailing slash because ZIP entry names are forward-slash relative paths.
|
|
||||||
const PluginOutputArchivePrefix = "cache/plugin-output/"
|
|
||||||
|
|
||||||
// addPluginOutput walks srcDir (typically $FILES_PATH/plugin) and copies every
|
|
||||||
// file under it into zw at PluginOutputArchivePrefix + relPath.
|
|
||||||
//
|
|
||||||
// Returns the number of files added. A missing srcDir is not an error: it
|
|
||||||
// just means no plugin has produced output yet, so the section stays empty.
|
|
||||||
func addPluginOutput(zw *zip.Writer, srcDir string) (int, error) {
|
|
||||||
info, err := os.Stat(srcDir)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("stat %s: %w", srcDir, err)
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
return 0, fmt.Errorf("%s is not a directory", srcDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
count := 0
|
|
||||||
err = filepath.WalkDir(srcDir, func(path string, d os.DirEntry, walkErr error) error {
|
|
||||||
if walkErr != nil {
|
|
||||||
return walkErr
|
|
||||||
}
|
|
||||||
if d.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rel, err := filepath.Rel(srcDir, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
entry := PluginOutputArchivePrefix + filepath.ToSlash(rel)
|
|
||||||
|
|
||||||
w, err := zw.Create(entry)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
if _, err := io.Copy(w, f); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return count, fmt.Errorf("walk %s: %w", srcDir, err)
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractPluginOutput pulls every entry under PluginOutputArchivePrefix from
|
|
||||||
// the ZIP and writes it under dstDir. Existing files are overwritten when
|
|
||||||
// overwrite=true; otherwise they're left alone (treats existing files as the
|
|
||||||
// "mine wins" choice — same toggle the metadata import uses).
|
|
||||||
//
|
|
||||||
// Path traversal is rejected: any entry whose relative path resolves outside
|
|
||||||
// dstDir is skipped with a warning.
|
|
||||||
func extractPluginOutput(zr *zip.Reader, dstDir string, overwrite bool) (written int, skipped int, err error) {
|
|
||||||
if dstDir == "" {
|
|
||||||
return 0, 0, fmt.Errorf("plugin-output destination dir is empty")
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("mkdir %s: %w", dstDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanRoot, err := filepath.Abs(dstDir)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("abs %s: %w", dstDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, f := range zr.File {
|
|
||||||
if !strings.HasPrefix(f.Name, PluginOutputArchivePrefix) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(f.Name, "/") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rel := strings.TrimPrefix(f.Name, PluginOutputArchivePrefix)
|
|
||||||
dst := filepath.Join(cleanRoot, filepath.FromSlash(rel))
|
|
||||||
|
|
||||||
// Reject anything that escapes the dst root.
|
|
||||||
if !strings.HasPrefix(dst, cleanRoot+string(os.PathSeparator)) && dst != cleanRoot {
|
|
||||||
log.Printf("[Snapshot] import: skipping path-escaping entry %q", f.Name)
|
|
||||||
skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !overwrite {
|
|
||||||
if _, err := os.Stat(dst); err == nil {
|
|
||||||
skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
||||||
return written, skipped, fmt.Errorf("mkdir for %s: %w", dst, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := writeZipFile(f, dst); err != nil {
|
|
||||||
return written, skipped, err
|
|
||||||
}
|
|
||||||
written++
|
|
||||||
}
|
|
||||||
return written, skipped, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeZipFile(f *zip.File, dst string) error {
|
|
||||||
rc, err := f.Open()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open zip entry %s: %w", f.Name, err)
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
|
|
||||||
out, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create %s: %w", dst, err)
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
if _, err := io.Copy(out, rc); err != nil {
|
|
||||||
return fmt.Errorf("write %s: %w", dst, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package snapshot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/metazla/meta-core/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ExportOptions controls what an export includes.
|
|
||||||
type ExportOptions struct {
|
|
||||||
IncludePluginOutput bool
|
|
||||||
// PluginOutputDir is the absolute path on disk that holds plugin-produced
|
|
||||||
// artifacts (typically $FILES_PATH/plugin). Required when
|
|
||||||
// IncludePluginOutput is true.
|
|
||||||
PluginOutputDir string
|
|
||||||
// SourceHost is recorded in the manifest so imports can attribute
|
|
||||||
// where the snapshot came from (defaults to OS hostname).
|
|
||||||
SourceHost string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exporter writes snapshot ZIPs from a live Redis store.
|
|
||||||
type Exporter struct {
|
|
||||||
storage *storage.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewExporter(stor *storage.Client) *Exporter {
|
|
||||||
return &Exporter{storage: stor}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export streams a ZIP archive to w. The archive layout is:
|
|
||||||
//
|
|
||||||
// manifest.json
|
|
||||||
// index.json
|
|
||||||
// metadata/{cid}.json (one per known CID)
|
|
||||||
//
|
|
||||||
// Optional sections (hash-index, plugin-output) are gated by ExportOptions
|
|
||||||
// but are not yet implemented — they live on filesystems meta-core does not
|
|
||||||
// own, and need cross-service collection. The flag is plumbed for forward
|
|
||||||
// compatibility but does nothing today.
|
|
||||||
func (e *Exporter) Export(w io.Writer, opts ExportOptions) (Manifest, error) {
|
|
||||||
if e.storage == nil || !e.storage.IsConnected() {
|
|
||||||
return Manifest{}, fmt.Errorf("storage not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
hashIDs, err := e.storage.GetAllHashIDs()
|
|
||||||
if err != nil {
|
|
||||||
return Manifest{}, fmt.Errorf("list hashIDs: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
zw := zip.NewWriter(w)
|
|
||||||
defer zw.Close()
|
|
||||||
|
|
||||||
// 1. manifest.json — written last after we know FileCount, but we need
|
|
||||||
// the entry order to put it first in the archive for readers. Solve
|
|
||||||
// by buffering in memory: it's tiny.
|
|
||||||
host := opts.SourceHost
|
|
||||||
if host == "" {
|
|
||||||
if h, err := os.Hostname(); err == nil {
|
|
||||||
host = h
|
|
||||||
}
|
|
||||||
}
|
|
||||||
manifest := Manifest{
|
|
||||||
SchemaVersion: ManifestSchemaVersion,
|
|
||||||
GeneratedAt: time.Now().UTC(),
|
|
||||||
SourceHost: host,
|
|
||||||
FileCount: len(hashIDs),
|
|
||||||
Includes: Includes{
|
|
||||||
Metadata: true,
|
|
||||||
PluginOutput: opts.IncludePluginOutput,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if err := writeJSON(zw, ManifestFile, manifest); err != nil {
|
|
||||||
return Manifest{}, fmt.Errorf("write manifest: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. index.json — flat list of CIDs. Redundant with metadata/ but
|
|
||||||
// cheap and useful for readers that only need the keyset.
|
|
||||||
if err := writeJSON(zw, IndexFile, IndexFileBody{HashIDs: hashIDs}); err != nil {
|
|
||||||
return Manifest{}, fmt.Errorf("write index: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. metadata/{cid}.json — one nested document per CID.
|
|
||||||
for _, hashID := range hashIDs {
|
|
||||||
flat, err := e.storage.GetMetadataFlat(hashID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[Snapshot] export: skipping %s: %v", hashID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(flat) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nested := Reconstruct(flat)
|
|
||||||
entry := MetadataDir + hashID + ".json"
|
|
||||||
if err := writeJSON(zw, entry, nested); err != nil {
|
|
||||||
return Manifest{}, fmt.Errorf("write %s: %w", entry, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. cache/plugin-output/* — optional. Walks the on-disk plugin dir and
|
|
||||||
// streams each file into the ZIP. Heavy (~120 MB on a populated dev
|
|
||||||
// stack), so it's gated by the ExportOptions toggle.
|
|
||||||
if opts.IncludePluginOutput {
|
|
||||||
if opts.PluginOutputDir == "" {
|
|
||||||
return Manifest{}, fmt.Errorf("PluginOutputDir is required when IncludePluginOutput is set")
|
|
||||||
}
|
|
||||||
n, err := addPluginOutput(zw, opts.PluginOutputDir)
|
|
||||||
if err != nil {
|
|
||||||
return Manifest{}, fmt.Errorf("add plugin-output: %w", err)
|
|
||||||
}
|
|
||||||
log.Printf("[Snapshot] exported %d plugin-output files from %s", n, opts.PluginOutputDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[Snapshot] exported %d files (host=%s, pluginOutput=%v)",
|
|
||||||
len(hashIDs), host, opts.IncludePluginOutput)
|
|
||||||
return manifest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(zw *zip.Writer, name string, body interface{}) error {
|
|
||||||
w, err := zw.Create(name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
enc := json.NewEncoder(w)
|
|
||||||
enc.SetIndent("", " ")
|
|
||||||
return enc.Encode(body)
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
package snapshot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Flat keys use "/" as the separator. Property paths inside Redis are
|
|
||||||
// stored as e.g. "fileinfo/streamdetails/video/0/codec".
|
|
||||||
const flatSep = "/"
|
|
||||||
|
|
||||||
// numericKey matches a path segment that is a non-negative decimal integer.
|
|
||||||
// Used to detect array slots (".../video/0/codec").
|
|
||||||
var numericKey = regexp.MustCompile(`^\d+$`)
|
|
||||||
|
|
||||||
// Reconstruct rebuilds a nested JSON-shaped value from a flat property map.
|
|
||||||
// Keys are property paths (no leading slash, no file:{cid}/ prefix).
|
|
||||||
//
|
|
||||||
// Sibling keys whose path segments are all sequential integers starting at 0
|
|
||||||
// are emitted as JSON arrays. Other groups become JSON objects. Leaf values
|
|
||||||
// are coerced from string to int/float/bool/null when they parse cleanly so
|
|
||||||
// the exported JSON is human-readable; on import we round-trip back to strings.
|
|
||||||
func Reconstruct(flat map[string]string) interface{} {
|
|
||||||
root := map[string]interface{}{}
|
|
||||||
for path, value := range flat {
|
|
||||||
parts := strings.Split(path, flatSep)
|
|
||||||
setPath(root, parts, parseValue(value))
|
|
||||||
}
|
|
||||||
return convertNumericMapsToArrays(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flatten is the inverse of Reconstruct: walks a nested value and produces a
|
|
||||||
// flat property map whose values are all strings (Redis-ready).
|
|
||||||
//
|
|
||||||
// Arrays become numeric path segments ("genre/0", "genre/1"). Nil/missing
|
|
||||||
// values are stored as "" to preserve the field's presence — this matches
|
|
||||||
// what flattenMetadata in the TS implementation does.
|
|
||||||
func Flatten(value interface{}) map[string]string {
|
|
||||||
out := map[string]string{}
|
|
||||||
flattenInto(value, "", out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func setPath(root map[string]interface{}, parts []string, leaf interface{}) {
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cur := root
|
|
||||||
for i, part := range parts {
|
|
||||||
if i == len(parts)-1 {
|
|
||||||
cur[part] = leaf
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next, ok := cur[part].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
next = map[string]interface{}{}
|
|
||||||
cur[part] = next
|
|
||||||
}
|
|
||||||
cur = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertNumericMapsToArrays(v interface{}) interface{} {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case map[string]interface{}:
|
|
||||||
// Recurse first so child arrays are detected before parent.
|
|
||||||
for k, child := range t {
|
|
||||||
t[k] = convertNumericMapsToArrays(child)
|
|
||||||
}
|
|
||||||
if !looksLikeArray(t) {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
// Sort numerically and emit as []interface{}.
|
|
||||||
idx := make([]int, 0, len(t))
|
|
||||||
for k := range t {
|
|
||||||
n, _ := strconv.Atoi(k)
|
|
||||||
idx = append(idx, n)
|
|
||||||
}
|
|
||||||
sort.Ints(idx)
|
|
||||||
arr := make([]interface{}, len(idx))
|
|
||||||
for i, n := range idx {
|
|
||||||
arr[i] = t[strconv.Itoa(n)]
|
|
||||||
}
|
|
||||||
return arr
|
|
||||||
case []interface{}:
|
|
||||||
for i := range t {
|
|
||||||
t[i] = convertNumericMapsToArrays(t[i])
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
default:
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// looksLikeArray returns true when every key in m is a non-negative integer
|
|
||||||
// and the keys form a contiguous range starting at 0. Empty maps return false
|
|
||||||
// — we can't tell whether the original was {} or [], so prefer {}.
|
|
||||||
func looksLikeArray(m map[string]interface{}) bool {
|
|
||||||
if len(m) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
seen := make(map[int]bool, len(m))
|
|
||||||
for k := range m {
|
|
||||||
if !numericKey.MatchString(k) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(k)
|
|
||||||
if err != nil || n < 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
seen[n] = true
|
|
||||||
}
|
|
||||||
for i := 0; i < len(m); i++ {
|
|
||||||
if !seen[i] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func flattenInto(v interface{}, path string, out map[string]string) {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case nil:
|
|
||||||
out[path] = ""
|
|
||||||
case map[string]interface{}:
|
|
||||||
if len(t) == 0 && path != "" {
|
|
||||||
// Empty objects don't have a Redis representation; skip.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for k, child := range t {
|
|
||||||
next := k
|
|
||||||
if path != "" {
|
|
||||||
next = path + flatSep + k
|
|
||||||
}
|
|
||||||
flattenInto(child, next, out)
|
|
||||||
}
|
|
||||||
case []interface{}:
|
|
||||||
for i, child := range t {
|
|
||||||
next := strconv.Itoa(i)
|
|
||||||
if path != "" {
|
|
||||||
next = path + flatSep + next
|
|
||||||
}
|
|
||||||
flattenInto(child, next, out)
|
|
||||||
}
|
|
||||||
case string:
|
|
||||||
out[path] = t
|
|
||||||
case bool:
|
|
||||||
if t {
|
|
||||||
out[path] = "true"
|
|
||||||
} else {
|
|
||||||
out[path] = "false"
|
|
||||||
}
|
|
||||||
case float64:
|
|
||||||
// JSON numbers always decode to float64; emit ints without decimals.
|
|
||||||
if t == float64(int64(t)) {
|
|
||||||
out[path] = strconv.FormatInt(int64(t), 10)
|
|
||||||
} else {
|
|
||||||
out[path] = strconv.FormatFloat(t, 'f', -1, 64)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
out[path] = fmt.Sprintf("%v", t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseValue mirrors the TS reconstruct logic: convert obvious primitives
|
|
||||||
// back to their typed form for nicer exported JSON, leave anything ambiguous
|
|
||||||
// as a string so we don't lose data (e.g. "5.1" channel layouts).
|
|
||||||
func parseValue(s string) interface{} {
|
|
||||||
if s == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch s {
|
|
||||||
case "true":
|
|
||||||
return true
|
|
||||||
case "false":
|
|
||||||
return false
|
|
||||||
case "null":
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Only treat as number when the string is a clean integer or decimal,
|
|
||||||
// no leading zeros / trailing junk. Matches the TS regex.
|
|
||||||
if isCleanNumber(s) {
|
|
||||||
if n, err := strconv.ParseFloat(s, 64); err == nil {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
var cleanNumber = regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
|
||||||
|
|
||||||
func isCleanNumber(s string) bool { return cleanNumber.MatchString(s) }
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
package snapshot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/metazla/meta-core/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mode controls how an import reconciles existing CIDs.
|
|
||||||
type Mode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ModeReplace deletes every existing property of an imported CID and
|
|
||||||
// writes the snapshot's values verbatim. Untouched CIDs are left alone.
|
|
||||||
ModeReplace Mode = "replace"
|
|
||||||
// ModeMerge keeps unmentioned existing properties and only touches the
|
|
||||||
// fields present in the snapshot. Conflict resolution within merge is
|
|
||||||
// controlled by Conflict.
|
|
||||||
ModeMerge Mode = "merge"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Conflict picks a winner for fields present on both sides during merge.
|
|
||||||
type Conflict string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ConflictMine keeps the existing (target) value when both sides have one.
|
|
||||||
ConflictMine Conflict = "mine"
|
|
||||||
// ConflictSource overwrites the existing value with the snapshot's value.
|
|
||||||
ConflictSource Conflict = "source"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ImportOptions controls how Import applies a snapshot.
|
|
||||||
type ImportOptions struct {
|
|
||||||
Mode Mode
|
|
||||||
Conflict Conflict
|
|
||||||
DryRun bool
|
|
||||||
// PluginOutputDir is the on-disk destination for cache/plugin-output/*
|
|
||||||
// entries. When empty, plugin-output is silently skipped on import.
|
|
||||||
PluginOutputDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileResult summarizes what happened (or would happen, in dry-run) for one CID.
|
|
||||||
type FileResult struct {
|
|
||||||
HashID string `json:"hashId"`
|
|
||||||
Added int `json:"added"` // new property keys written
|
|
||||||
Updated int `json:"updated"` // existing property keys whose value changed
|
|
||||||
Unchanged int `json:"unchanged"` // existing property keys whose value matched
|
|
||||||
Kept int `json:"kept"` // existing property keys preserved by ConflictMine
|
|
||||||
Deleted int `json:"deleted"` // existing property keys removed (replace mode)
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PluginOutputResult summarizes what happened to cache/plugin-output entries.
|
|
||||||
type PluginOutputResult struct {
|
|
||||||
Written int `json:"written"`
|
|
||||||
Skipped int `json:"skipped"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportResult is the response body for the import endpoint.
|
|
||||||
type ImportResult struct {
|
|
||||||
Mode Mode `json:"mode"`
|
|
||||||
Conflict Conflict `json:"conflict,omitempty"`
|
|
||||||
DryRun bool `json:"dryRun"`
|
|
||||||
TotalFiles int `json:"totalFiles"`
|
|
||||||
FilesOK int `json:"filesOk"`
|
|
||||||
FilesFailed int `json:"filesFailed"`
|
|
||||||
Files []FileResult `json:"files,omitempty"`
|
|
||||||
PluginOutput *PluginOutputResult `json:"pluginOutput,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Importer applies snapshot ZIPs to a live Redis store.
|
|
||||||
type Importer struct {
|
|
||||||
storage *storage.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewImporter(stor *storage.Client) *Importer {
|
|
||||||
return &Importer{storage: stor}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import reads a ZIP from r (which must support io.ReaderAt; callers pass
|
|
||||||
// *bytes.Reader after buffering the upload) and applies it to Redis.
|
|
||||||
func (i *Importer) Import(r io.ReaderAt, size int64, opts ImportOptions) (*ImportResult, error) {
|
|
||||||
if i.storage == nil || !i.storage.IsConnected() {
|
|
||||||
return nil, fmt.Errorf("storage not connected")
|
|
||||||
}
|
|
||||||
if opts.Mode != ModeReplace && opts.Mode != ModeMerge {
|
|
||||||
return nil, fmt.Errorf("invalid mode %q (want replace|merge)", opts.Mode)
|
|
||||||
}
|
|
||||||
if opts.Mode == ModeMerge && opts.Conflict != ConflictMine && opts.Conflict != ConflictSource {
|
|
||||||
return nil, fmt.Errorf("invalid conflict %q (want mine|source)", opts.Conflict)
|
|
||||||
}
|
|
||||||
|
|
||||||
zr, err := zip.NewReader(r, size)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("open zip: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var manifest Manifest
|
|
||||||
if err := readJSON(zr, ManifestFile, &manifest); err != nil {
|
|
||||||
return nil, fmt.Errorf("read manifest: %w", err)
|
|
||||||
}
|
|
||||||
if manifest.SchemaVersion > ManifestSchemaVersion {
|
|
||||||
return nil, fmt.Errorf("snapshot schemaVersion %d exceeds supported %d",
|
|
||||||
manifest.SchemaVersion, ManifestSchemaVersion)
|
|
||||||
}
|
|
||||||
if manifest.SchemaVersion < ManifestSchemaVersion {
|
|
||||||
return nil, fmt.Errorf("snapshot schemaVersion %d is no longer supported (current: %d); v1 used content-hash root keys which are incompatible with the UUID-rooted schema",
|
|
||||||
manifest.SchemaVersion, ManifestSchemaVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &ImportResult{
|
|
||||||
Mode: opts.Mode,
|
|
||||||
Conflict: opts.Conflict,
|
|
||||||
DryRun: opts.DryRun,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, f := range zr.File {
|
|
||||||
if !strings.HasPrefix(f.Name, MetadataDir) || !strings.HasSuffix(f.Name, ".json") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hashID := strings.TrimSuffix(strings.TrimPrefix(f.Name, MetadataDir), ".json")
|
|
||||||
if hashID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fr := i.applyOne(f, hashID, opts)
|
|
||||||
result.TotalFiles++
|
|
||||||
if fr.Error != "" {
|
|
||||||
result.FilesFailed++
|
|
||||||
} else {
|
|
||||||
result.FilesOK++
|
|
||||||
}
|
|
||||||
result.Files = append(result.Files, fr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plugin-output extraction. Only applied on real imports (not dry-run).
|
|
||||||
// If the snapshot doesn't contain a cache/plugin-output/ section, this is
|
|
||||||
// a no-op. If the destination is unset, we skip silently — the manifest's
|
|
||||||
// pluginOutput flag tells the caller it was on the source side either way.
|
|
||||||
if !opts.DryRun && opts.PluginOutputDir != "" && manifest.Includes.PluginOutput {
|
|
||||||
// Existing files are kept under ConflictMine / replace-doesn't-apply,
|
|
||||||
// overwritten under ConflictSource. Replace mode behaves like
|
|
||||||
// "overwrite" for cache files (it's the destructive choice for
|
|
||||||
// metadata; mirror that intent here).
|
|
||||||
overwrite := opts.Mode == ModeReplace || opts.Conflict == ConflictSource
|
|
||||||
written, skipped, err := extractPluginOutput(zr, opts.PluginOutputDir, overwrite)
|
|
||||||
if err != nil {
|
|
||||||
return result, fmt.Errorf("extract plugin-output: %w", err)
|
|
||||||
}
|
|
||||||
result.PluginOutput = &PluginOutputResult{Written: written, Skipped: skipped}
|
|
||||||
log.Printf("[Snapshot] import: plugin-output written=%d skipped=%d", written, skipped)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[Snapshot] import: mode=%s conflict=%s dryRun=%v files=%d ok=%d failed=%d",
|
|
||||||
opts.Mode, opts.Conflict, opts.DryRun, result.TotalFiles, result.FilesOK, result.FilesFailed)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *Importer) applyOne(f *zip.File, hashID string, opts ImportOptions) FileResult {
|
|
||||||
fr := FileResult{HashID: hashID}
|
|
||||||
|
|
||||||
rc, err := f.Open()
|
|
||||||
if err != nil {
|
|
||||||
fr.Error = fmt.Sprintf("open entry: %v", err)
|
|
||||||
return fr
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
|
|
||||||
var nested interface{}
|
|
||||||
if err := json.NewDecoder(rc).Decode(&nested); err != nil {
|
|
||||||
fr.Error = fmt.Sprintf("decode json: %v", err)
|
|
||||||
return fr
|
|
||||||
}
|
|
||||||
incoming := Flatten(nested)
|
|
||||||
|
|
||||||
existing, err := i.storage.GetMetadataFlat(hashID)
|
|
||||||
if err != nil {
|
|
||||||
fr.Error = fmt.Sprintf("read existing: %v", err)
|
|
||||||
return fr
|
|
||||||
}
|
|
||||||
if existing == nil {
|
|
||||||
existing = map[string]string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch opts.Mode {
|
|
||||||
case ModeReplace:
|
|
||||||
// Diff for reporting.
|
|
||||||
toWrite := map[string]string{}
|
|
||||||
for k, v := range incoming {
|
|
||||||
toWrite[k] = v
|
|
||||||
if cur, ok := existing[k]; ok {
|
|
||||||
if cur == v {
|
|
||||||
fr.Unchanged++
|
|
||||||
} else {
|
|
||||||
fr.Updated++
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fr.Added++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for k := range existing {
|
|
||||||
if _, ok := incoming[k]; !ok {
|
|
||||||
fr.Deleted++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !opts.DryRun {
|
|
||||||
if _, err := i.storage.DeleteMetadata(hashID); err != nil {
|
|
||||||
fr.Error = fmt.Sprintf("delete existing: %v", err)
|
|
||||||
return fr
|
|
||||||
}
|
|
||||||
if err := i.storage.SetMetadataFlat(hashID, toWrite); err != nil {
|
|
||||||
fr.Error = fmt.Sprintf("write: %v", err)
|
|
||||||
return fr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case ModeMerge:
|
|
||||||
toWrite := map[string]string{}
|
|
||||||
for k, v := range incoming {
|
|
||||||
cur, present := existing[k]
|
|
||||||
switch {
|
|
||||||
case !present:
|
|
||||||
toWrite[k] = v
|
|
||||||
fr.Added++
|
|
||||||
case cur == v:
|
|
||||||
fr.Unchanged++
|
|
||||||
case opts.Conflict == ConflictSource:
|
|
||||||
toWrite[k] = v
|
|
||||||
fr.Updated++
|
|
||||||
case opts.Conflict == ConflictMine:
|
|
||||||
fr.Kept++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !opts.DryRun && len(toWrite) > 0 {
|
|
||||||
if _, err := i.storage.MergeMetadataFlat(hashID, toWrite); err != nil {
|
|
||||||
fr.Error = fmt.Sprintf("merge: %v", err)
|
|
||||||
return fr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fr
|
|
||||||
}
|
|
||||||
|
|
||||||
func readJSON(zr *zip.Reader, name string, out interface{}) error {
|
|
||||||
for _, f := range zr.File {
|
|
||||||
if f.Name != name {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rc, err := f.Open()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
return json.NewDecoder(rc).Decode(out)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("entry %q not found", name)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
// Package snapshot implements export/import/wipe of MetaMesh metadata.
|
|
||||||
//
|
|
||||||
// The on-disk format is a ZIP archive containing one nested-JSON file per
|
|
||||||
// indexed root. Schema is defined by ManifestSchemaVersion; readers must
|
|
||||||
// reject archives whose schemaVersion exceeds what they know.
|
|
||||||
//
|
|
||||||
// v2 (current): root identifiers are UUIDv7 (Crockford Base32). Entries
|
|
||||||
// under metadata/<root>.json carry the flat string properties; the cid
|
|
||||||
// reverse-index and the duplicates SET are NOT serialized — they are rebuilt
|
|
||||||
// transparently on import because the cids/<cid> key-set members ARE flat
|
|
||||||
// properties, and SetMetadataFlat / MergeMetadataFlat auto-register a
|
|
||||||
// reverse-index alias for every one they see (see
|
|
||||||
// internal/storage/cid_resolution.go: cidFromKeysetField). The legacy flat
|
|
||||||
// cid_*/midhash256 named fields are not recognised.
|
|
||||||
//
|
|
||||||
// v1 (legacy): roots were content-hash-typed tokens like "midhash256:abc".
|
|
||||||
// v1 archives can no longer be imported — they would resurrect the
|
|
||||||
// privileged-hashID schema this layer was built to retire.
|
|
||||||
package snapshot
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// ManifestSchemaVersion is the on-disk format version. Bump when the layout
|
|
||||||
// of metadata/{root}.json or the manifest changes in a non-additive way.
|
|
||||||
const ManifestSchemaVersion = 2
|
|
||||||
|
|
||||||
// MetadataDir is the path inside the ZIP that holds per-CID JSON files.
|
|
||||||
const MetadataDir = "metadata/"
|
|
||||||
|
|
||||||
// ManifestFile is the path inside the ZIP that holds the manifest JSON.
|
|
||||||
const ManifestFile = "manifest.json"
|
|
||||||
|
|
||||||
// IndexFile is the path inside the ZIP that holds the file index (CID list).
|
|
||||||
const IndexFile = "index.json"
|
|
||||||
|
|
||||||
// Manifest is the metadata header stored at the root of every snapshot ZIP.
|
|
||||||
type Manifest struct {
|
|
||||||
SchemaVersion int `json:"schemaVersion"`
|
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
|
||||||
SourceHost string `json:"sourceHost,omitempty"`
|
|
||||||
MetaCoreVer string `json:"metaCoreVersion,omitempty"`
|
|
||||||
FileCount int `json:"fileCount"`
|
|
||||||
Includes Includes `json:"includes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Includes records which optional sections are present in a snapshot.
|
|
||||||
type Includes struct {
|
|
||||||
Metadata bool `json:"metadata"`
|
|
||||||
PluginOutput bool `json:"pluginOutput,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// IndexFileBody is the structure of index.json inside the ZIP.
|
|
||||||
type IndexFileBody struct {
|
|
||||||
HashIDs []string `json:"hashIds"`
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package snapshot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/metazla/meta-core/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WipeScope picks which classes of data to clear.
|
|
||||||
type WipeScope struct {
|
|
||||||
Metadata bool // file:* keys + file:__index__
|
|
||||||
}
|
|
||||||
|
|
||||||
// WipeResult reports what was cleared.
|
|
||||||
type WipeResult struct {
|
|
||||||
MetadataDeleted int64 `json:"metadataDeleted"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wiper clears metadata from Redis.
|
|
||||||
type Wiper struct {
|
|
||||||
storage *storage.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWiper(stor *storage.Client) *Wiper {
|
|
||||||
return &Wiper{storage: stor}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wipe applies the requested scope. Returns the count of files removed.
|
|
||||||
func (w *Wiper) Wipe(scope WipeScope) (*WipeResult, error) {
|
|
||||||
if w.storage == nil || !w.storage.IsConnected() {
|
|
||||||
return nil, fmt.Errorf("storage not connected")
|
|
||||||
}
|
|
||||||
res := &WipeResult{}
|
|
||||||
if scope.Metadata {
|
|
||||||
n, err := w.storage.ClearAllMetadata()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("clear metadata: %w", err)
|
|
||||||
}
|
|
||||||
res.MetadataDeleted = n
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
// The apply loop: folds committed truth changes into the cache.
|
||||||
|
//
|
||||||
|
// The cache's merged view keeps the v1 record layout — `file:<rid>/<field>`
|
||||||
|
// rows of the cache kv table — so every reader (record reads, search, the
|
||||||
|
// field index, FTS, counters, SSE and its snapshot) is unchanged; those rows
|
||||||
|
// are now derived, never written by a client. Each committed change re-merges
|
||||||
|
// only the (rid, key) it touched, from the truth votes of every cid in the
|
||||||
|
// group. Group membership is synthesised: every cid of a group appears as
|
||||||
|
// `file:<rid>/cids/<cid> = "true"` plus the `cid:<cid> → rid` lookup, whatever
|
||||||
|
// the cids/* claims say.
|
||||||
|
//
|
||||||
|
// Changes arrive in commit order (kv.Tx.OnCommit runs in the truth writer), so
|
||||||
|
// `meta:applied_seq` only ever covers a prefix of the truth log. A crash
|
||||||
|
// between the truth commit and the cache apply leaves the cache behind, never
|
||||||
|
// wrong; Connect catches up from truth.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/kv"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
appliedSeqKey = "meta:applied_seq"
|
||||||
|
// rebuiltAtKey holds the stream id at which the cache was last rebuilt.
|
||||||
|
// An SSE cursor older than it gets `gap`: the rebuild emitted no
|
||||||
|
// per-key events, so the consumer must take a snapshot.
|
||||||
|
rebuiltAtKey = "meta:rebuilt_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
var localFields = []string{"filePath", "sizeByte", "mtimeNano"}
|
||||||
|
|
||||||
|
type applier struct {
|
||||||
|
c *Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
queue []change
|
||||||
|
applied int64
|
||||||
|
notify chan struct{}
|
||||||
|
|
||||||
|
wake chan struct{}
|
||||||
|
stop chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newApplier(c *Client, applied int64) *applier {
|
||||||
|
a := &applier{
|
||||||
|
c: c, applied: applied,
|
||||||
|
notify: make(chan struct{}),
|
||||||
|
wake: make(chan struct{}, 1),
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go a.run()
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *applier) enqueue(ch []change) {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.queue = append(a.queue, ch...)
|
||||||
|
a.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case a.wake <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait blocks until the cache has applied every change up to seq.
|
||||||
|
func (a *applier) wait(ctx context.Context, seq int64) error {
|
||||||
|
for {
|
||||||
|
a.mu.Lock()
|
||||||
|
if a.applied >= seq {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n := a.notify
|
||||||
|
a.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-n:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-a.stop:
|
||||||
|
return errors.New("storage closed before the cache applied the write")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *applier) close() {
|
||||||
|
close(a.stop)
|
||||||
|
<-a.done
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyBatchMax = 2000
|
||||||
|
|
||||||
|
func (a *applier) run() {
|
||||||
|
defer close(a.done)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.wake:
|
||||||
|
case <-a.stop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
a.mu.Lock()
|
||||||
|
n := min(len(a.queue), applyBatchMax)
|
||||||
|
batch := append([]change(nil), a.queue[:n]...)
|
||||||
|
a.mu.Unlock()
|
||||||
|
if len(batch) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := a.c.applyBatch(batch); err != nil {
|
||||||
|
log.Printf("[Storage] apply of %d changes failed, retrying: %v", len(batch), err)
|
||||||
|
select {
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
continue
|
||||||
|
case <-a.stop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
a.queue = a.queue[n:]
|
||||||
|
if s := batch[len(batch)-1].seq; s > a.applied {
|
||||||
|
a.applied = s
|
||||||
|
}
|
||||||
|
close(a.notify)
|
||||||
|
a.notify = make(chan struct{})
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyBatch folds committed changes into the cache in one transaction.
|
||||||
|
func (c *Client) applyBatch(batch []change) error {
|
||||||
|
s, err := c.kv()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
return s.Update(ctx, func(tx *kv.Tx) error {
|
||||||
|
return c.applyTx(tx, batch)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) applyTx(tx *kv.Tx, batch []change) error {
|
||||||
|
ctx := tx.Context()
|
||||||
|
marks := map[string]map[string]bool{}
|
||||||
|
mark := func(rid, key string) {
|
||||||
|
if marks[rid] == nil {
|
||||||
|
marks[rid] = map[string]bool{}
|
||||||
|
}
|
||||||
|
marks[rid][key] = true
|
||||||
|
}
|
||||||
|
all := map[string]bool{}
|
||||||
|
dirty := map[string]bool{}
|
||||||
|
var maxSeq int64
|
||||||
|
for _, it := range batch {
|
||||||
|
maxSeq = max(maxSeq, it.seq)
|
||||||
|
if it.regroup != nil {
|
||||||
|
loser, survivor := it.regroup[0], it.regroup[1]
|
||||||
|
if _, err := tx.DeletePrefix(recordPrefix(loser), 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Delete(rootKey(loser)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := deleteSearchRowTx(tx, loser); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(marks, loser)
|
||||||
|
cids, err := c.truthMembers(ctx, survivor)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, cid := range cids {
|
||||||
|
if _, err := syncMember(tx, cid, survivor); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all[survivor], dirty[survivor] = true, true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rid, err := c.truthRid(ctx, it.cid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newRoot, err := syncMember(tx, it.cid, rid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if newRoot {
|
||||||
|
dirty[rid] = true
|
||||||
|
}
|
||||||
|
if it.key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.live {
|
||||||
|
_, err = tx.Exec(`INSERT OR IGNORE INTO claim_by_pid(pid, cid, key) VALUES(?, ?, ?)`, it.pid, it.cid, it.key)
|
||||||
|
} else {
|
||||||
|
_, err = tx.Exec(`DELETE FROM claim_by_pid WHERE pid = ? AND cid = ? AND key = ?`, it.pid, it.cid, it.key)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mark(rid, it.key)
|
||||||
|
}
|
||||||
|
for rid := range all {
|
||||||
|
cids, err := cidsForRoot(ctx, tx, rid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keys, err := c.claimKeys(ctx, cids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, k := range append(keys, localFields...) {
|
||||||
|
mark(rid, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for rid, keys := range marks {
|
||||||
|
cids, err := cidsForRoot(ctx, tx, rid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for key := range keys {
|
||||||
|
changed, err := c.remergeTx(tx, rid, cids, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed && affectsHaystack(key) {
|
||||||
|
dirty[rid] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for rid := range dirty {
|
||||||
|
if err := refreshSearchTx(tx, rid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxSeq > 0 {
|
||||||
|
return tx.Put(appliedSeqKey, strconv.FormatInt(maxSeq, 10))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncMember makes cid a member of rid in the cache: the lookup row, the
|
||||||
|
// synthesised key-set member, and the root. Reports whether rid is new.
|
||||||
|
func syncMember(tx *kv.Tx, cid, rid string) (bool, error) {
|
||||||
|
cur, _, err := tx.Get(tx.Context(), CIDIndexPrefix+cid)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if cur != rid {
|
||||||
|
if cur != "" {
|
||||||
|
if _, err := tx.Delete(recordPrefix(cur) + CIDsKeyPrefix + cid); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Put(CIDIndexPrefix+cid, rid); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Put(recordPrefix(rid)+CIDsKeyPrefix+cid, "true"); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return tx.Set(rootKey(rid), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// remergeTx recomputes file:<rid>/<key> from the group's votes (plus the
|
||||||
|
// box-local facts for filePath / sizeByte / mtimeNano) and reports whether
|
||||||
|
// the merged row changed.
|
||||||
|
func (c *Client) remergeTx(tx *kv.Tx, rid string, cids []string, key string) (bool, error) {
|
||||||
|
if strings.HasPrefix(key, CIDsKeyPrefix) {
|
||||||
|
return false, nil // membership is synthesised, never merged
|
||||||
|
}
|
||||||
|
votes, err := c.votesFor(tx.Context(), cids, key)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
res := Merge(votes, "")
|
||||||
|
val, ok := res.Value, res.OK
|
||||||
|
if isLocalField(key) {
|
||||||
|
lv, lok, err := localFact(tx, cids, key)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
// The disk is the truth for where and when a file is; a signed
|
||||||
|
// sizeByte (a content fact) wins over the on-disk size.
|
||||||
|
if lok && (key != "sizeByte" || !ok) {
|
||||||
|
val, ok = lv, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k := recordPrefix(rid) + key
|
||||||
|
if ok {
|
||||||
|
return tx.Set(k, val)
|
||||||
|
}
|
||||||
|
return tx.Delete(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// localFact reads a box-local field from local_file for the group's cids.
|
||||||
|
func localFact(tx *kv.Tx, cids []string, key string) (string, bool, error) {
|
||||||
|
if len(cids) == 0 {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
args := make([]any, len(cids))
|
||||||
|
for i, c := range cids {
|
||||||
|
args[i] = c
|
||||||
|
}
|
||||||
|
var path string
|
||||||
|
var size, mtime int64
|
||||||
|
err := tx.QueryRow(`SELECT path, size, mtime FROM local_file WHERE midhash IN (?`+
|
||||||
|
strings.Repeat(",?", len(cids)-1)+`) ORDER BY path LIMIT 1`, args...).Scan(&path, &size, &mtime)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "filePath":
|
||||||
|
return path, true, nil
|
||||||
|
case "sizeByte":
|
||||||
|
return strconv.FormatInt(size, 10), true, nil
|
||||||
|
default:
|
||||||
|
return strconv.FormatInt(mtime, 10), true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// truthMembers lists every cid of rid's group (scans cid_group: truth has no
|
||||||
|
// rid index; only regroups and rebuilds call this).
|
||||||
|
func (c *Client) truthMembers(ctx context.Context, rid string) ([]string, error) {
|
||||||
|
rows, err := c.truth.DB().QueryContext(ctx, `SELECT cid FROM cid_group WHERE rid = ?`, rid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var cid string
|
||||||
|
if err := rows.Scan(&cid); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, cid)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// truthLastSeq reads the newest committed seq.
|
||||||
|
func (c *Client) truthLastSeq(ctx context.Context) (int64, error) {
|
||||||
|
var v string
|
||||||
|
err := c.truth.DB().QueryRowContext(ctx, `SELECT value FROM meta WHERE name = ?`, lastSeqName).Scan(&v)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return strconv.ParseInt(v, 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// catchUp brings the cache level with truth at boot: nothing to do, a replay
|
||||||
|
// of the changes after applied_seq (found by a full scan — there is no seq
|
||||||
|
// index, and this only runs after an unclean shutdown), or a full rebuild
|
||||||
|
// when the cache is missing or a group changed while it was behind.
|
||||||
|
func (c *Client) catchUp() (int64, error) {
|
||||||
|
s := c.store
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
last, err := c.truthLastSeq(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
appliedStr, has, err := s.Get(ctx, appliedSeqKey)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
applied, _ := strconv.ParseInt(appliedStr, 10, 64)
|
||||||
|
if has && applied >= last {
|
||||||
|
return applied, nil
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
if last == 0 {
|
||||||
|
return 0, nil // fresh store
|
||||||
|
}
|
||||||
|
log.Printf("[Storage] cache is empty, rebuilding from truth (last_seq %d)", last)
|
||||||
|
return last, c.rebuild(ctx, last)
|
||||||
|
}
|
||||||
|
rows, err := c.truth.DB().QueryContext(ctx,
|
||||||
|
`SELECT cid, key, pid, value IS NOT NULL, seq FROM claim WHERE seq > ? ORDER BY seq`, applied)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var missed []change
|
||||||
|
regroup := false
|
||||||
|
for rows.Next() {
|
||||||
|
var ch change
|
||||||
|
if err := rows.Scan(&ch.cid, &ch.key, &ch.pid, &ch.live, &ch.seq); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(ch.key, CIDsKeyPrefix) {
|
||||||
|
regroup = true
|
||||||
|
}
|
||||||
|
missed = append(missed, ch)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if regroup {
|
||||||
|
log.Printf("[Storage] cache behind by %d changes including a group link, rebuilding", len(missed))
|
||||||
|
return last, c.rebuild(ctx, last)
|
||||||
|
}
|
||||||
|
log.Printf("[Storage] cache behind (applied %d < last %d), replaying %d changes", applied, last, len(missed))
|
||||||
|
for start := 0; start < len(missed); start += applyBatchMax {
|
||||||
|
end := min(start+applyBatchMax, len(missed))
|
||||||
|
if err := c.applyBatch(missed[start:end]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.Update(ctx, func(tx *kv.Tx) error { return tx.Put(appliedSeqKey, strconv.FormatInt(last, 10)) }); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return last, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild drops every derived row of the cache and recomputes it from truth.
|
||||||
|
// Exposed for tests and operators; boot calls it when the cache is missing.
|
||||||
|
func (c *Client) Rebuild() error {
|
||||||
|
if _, err := c.truthStore(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
last, err := c.truthLastSeq(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.rebuild(ctx, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rebuildBatch = 500
|
||||||
|
|
||||||
|
func (c *Client) rebuild(ctx context.Context, last int64) error {
|
||||||
|
s := c.store
|
||||||
|
started := time.Now()
|
||||||
|
for _, prefix := range []string{recordNS, CIDIndexPrefix, rootsNS, ftsNS} {
|
||||||
|
for {
|
||||||
|
var n int64
|
||||||
|
if err := s.Update(ctx, func(tx *kv.Tx) error {
|
||||||
|
tx.Quiet()
|
||||||
|
var err error
|
||||||
|
n, err = tx.DeletePrefix(prefix, bulkBatch)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("clear %s: %w", prefix, err)
|
||||||
|
}
|
||||||
|
if n < bulkBatch {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.Update(ctx, func(tx *kv.Tx) error {
|
||||||
|
if _, err := tx.Exec(`DELETE FROM fts`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := tx.Exec(`DELETE FROM claim_by_pid`)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := map[string][]string{}
|
||||||
|
var order []string
|
||||||
|
rows, err := c.truth.DB().QueryContext(ctx, `SELECT cid, rid FROM cid_group`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var cid, rid string
|
||||||
|
if err := rows.Scan(&cid, &rid); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, ok := groups[rid]; !ok {
|
||||||
|
order = append(order, rid)
|
||||||
|
}
|
||||||
|
groups[rid] = append(groups[rid], cid)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for start := 0; start < len(order); start += rebuildBatch {
|
||||||
|
end := min(start+rebuildBatch, len(order))
|
||||||
|
if err := s.Update(ctx, func(tx *kv.Tx) error {
|
||||||
|
tx.Quiet()
|
||||||
|
for _, rid := range order[start:end] {
|
||||||
|
cids := groups[rid]
|
||||||
|
for _, cid := range cids {
|
||||||
|
if _, err := syncMember(tx, cid, rid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys, err := c.claimKeys(ctx, cids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, key := range append(keys, localFields...) {
|
||||||
|
if _, err := c.remergeTx(tx, rid, cids, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := refreshSearchTx(tx, rid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("rebuild records: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.rebuildClaimIndex(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.RebuildCounters(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rebuiltAt := strconv.FormatInt(time.Now().UnixMilli(), 10) + "-0"
|
||||||
|
if err := s.Update(ctx, func(tx *kv.Tx) error {
|
||||||
|
if err := tx.Put(rebuiltAtKey, rebuiltAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Put(appliedSeqKey, strconv.FormatInt(last, 10))
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[Storage] cache rebuilt: %d records in %s", len(order), time.Since(started).Round(time.Millisecond))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuildClaimIndex refills claim_by_pid from truth, a page at a time.
|
||||||
|
func (c *Client) rebuildClaimIndex(ctx context.Context) error {
|
||||||
|
type row struct {
|
||||||
|
pid int64
|
||||||
|
cid, key string
|
||||||
|
}
|
||||||
|
var after row
|
||||||
|
first := true
|
||||||
|
for {
|
||||||
|
q := `SELECT pid, cid, key FROM claim WHERE value IS NOT NULL`
|
||||||
|
var args []any
|
||||||
|
if !first {
|
||||||
|
q += ` AND (cid, key, pid) > (?, ?, ?)`
|
||||||
|
args = append(args, after.cid, after.key, after.pid)
|
||||||
|
}
|
||||||
|
q += ` ORDER BY cid, key, pid LIMIT 5000`
|
||||||
|
rs, err := c.truth.DB().QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var page []row
|
||||||
|
for rs.Next() {
|
||||||
|
var r row
|
||||||
|
if err := rs.Scan(&r.pid, &r.cid, &r.key); err != nil {
|
||||||
|
rs.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
page = append(page, r)
|
||||||
|
}
|
||||||
|
if err := rs.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(page) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := c.store.Update(ctx, func(tx *kv.Tx) error {
|
||||||
|
for _, r := range page {
|
||||||
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO claim_by_pid(pid, cid, key) VALUES(?, ?, ?)`, r.pid, r.cid, r.key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
after, first = page[len(page)-1], false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebuiltAt is the stream id of the last cache rebuild ("" if never).
|
||||||
|
func (c *Client) RebuiltAt() string {
|
||||||
|
s, err := c.kv()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
|
defer cancel()
|
||||||
|
v, _, _ := s.Get(ctx, rebuiltAtKey)
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -3,38 +3,24 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/metazla/meta-core/internal/cid"
|
"github.com/metazla/meta-core/internal/cid"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/metazla/meta-core/internal/kv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// This file implements the CID-resolution layer that lives on top of the
|
// The cid → record resolution layer, read side. A record (rid) is a group of
|
||||||
// flat metadata storage in client.go. The short version:
|
// cids known to be the same content; the group lives in truth's cid_group and
|
||||||
|
// is mirrored into the cache as
|
||||||
//
|
//
|
||||||
// - Roots are opaque UUIDv7s — file:<uuid>/<property> for every property.
|
// cid:<bareCid> → <rid>
|
||||||
// The UUID is an internal implementation detail; the mental model is
|
// file:<rid>/cids/<bareCid> = "true" (synthesised membership, one per cid)
|
||||||
// "N CIDs → one record of keys→metadata".
|
|
||||||
// - Every known CID for a file is registered as a reverse-index entry:
|
|
||||||
// cid:<bareCid> → <uuid> (Redis STRING)
|
|
||||||
// and as a member of the per-root bare-CID key-set:
|
|
||||||
// file:<uuid>/cids/<bareCid> = "true" (flat key, sentinel value)
|
|
||||||
// - A CID is self-describing: its algorithm is recoverable from the CIDv1
|
|
||||||
// multicodec (see internal/cid), so there is no per-algorithm field name
|
|
||||||
// and no <algo>: token prefix. The canonical externally-visible CID is
|
|
||||||
// *derived by rank* from the key-set on read (GetMetadataDocument), never
|
|
||||||
// stored.
|
|
||||||
//
|
//
|
||||||
// Auto-alias hooks in SetProperty/SetMetadataFlat/MergeMetadataFlat catch
|
// by the apply loop (apply.go). A CID is self-describing (its algorithm is in
|
||||||
// any write of a cids/<cid> field and register the reverse-index entry, so
|
// the CIDv1 multicodec, see internal/cid); the canonical externally-visible
|
||||||
// writers that just emit the key-set members don't need to know about the
|
// CID is derived by rank on read (GetMetadataDocument), never stored.
|
||||||
// reverse index at all. The hook helpers are *Locked — they assume c.mu is
|
|
||||||
// already held by the caller, avoiding nested RLock (which can deadlock
|
|
||||||
// against a waiting writer).
|
|
||||||
|
|
||||||
// CIDIndexPrefix is the Redis key prefix for the reverse index. Combined
|
// CIDIndexPrefix is the key prefix for the reverse index. Combined
|
||||||
// with a bare CID, it forms the full key: "cid:bafk…".
|
// with a bare CID, it forms the full key: "cid:bafk…".
|
||||||
const CIDIndexPrefix = "cid:"
|
const CIDIndexPrefix = "cid:"
|
||||||
|
|
||||||
@@ -45,154 +31,6 @@ const CIDIndexPrefix = "cid:"
|
|||||||
// reverse-index entries that need cleanup).
|
// reverse-index entries that need cleanup).
|
||||||
const CIDsKeyPrefix = "cids/"
|
const CIDsKeyPrefix = "cids/"
|
||||||
|
|
||||||
// DuplicatesField is the per-root SET key holding additional file paths
|
|
||||||
// whose content hashes to the same CIDs as this root. The watcher writes
|
|
||||||
// here when it sees a new path for content that's already indexed; meta-dup
|
|
||||||
// reads from it.
|
|
||||||
const DuplicatesField = "duplicates"
|
|
||||||
|
|
||||||
// buildCIDIndexKey returns the absolute Redis key for a reverse-index entry.
|
|
||||||
// cidStr is a bare multibase CIDv1 ("bafk…").
|
|
||||||
func (c *Client) buildCIDIndexKey(cidStr string) string {
|
|
||||||
return c.buildKey(CIDIndexPrefix + cidStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mint creates a new root with filePath/sizeByte/mtimeNano and registers it
|
|
||||||
// in the file index. Returns the new UUID. The caller is expected to follow
|
|
||||||
// up with AddAlias for at least one CID — a root without any alias is
|
|
||||||
// reachable only via the index, which works but defeats the point.
|
|
||||||
func (c *Client) Mint(filePath string, size, mtimeNano int64) (string, error) {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return "", fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
uuid, err := NewUUID()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("uuid: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
prefix := c.buildKeyPrefix(uuid)
|
|
||||||
pipe := c.client.Pipeline()
|
|
||||||
pipe.Set(ctx, prefix+"filePath", filePath, 0)
|
|
||||||
pipe.Set(ctx, prefix+"sizeByte", strconv.FormatInt(size, 10), 0)
|
|
||||||
pipe.Set(ctx, prefix+"mtimeNano", strconv.FormatInt(mtimeNano, 10), 0)
|
|
||||||
pipe.SAdd(ctx, c.buildIndexKey(), uuid)
|
|
||||||
c.addFieldsToPipe(ctx, pipe, uuid, []string{"filePath", "sizeByte", "mtimeNano"})
|
|
||||||
if _, err := pipe.Exec(ctx); err != nil {
|
|
||||||
return "", fmt.Errorf("mint pipeline: %w", err)
|
|
||||||
}
|
|
||||||
return uuid, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddAlias registers cidStr as a sibling CID of uuid: it writes the reverse
|
|
||||||
// index cid:<cidStr> → uuid and the key-set member cids/<cidStr> = "true".
|
|
||||||
// Idempotent: registering the same CID twice is a no-op.
|
|
||||||
func (c *Client) AddAlias(uuid, cidStr string) error {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
return c.addAliasLocked(ctx, uuid, cidStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addAliasLocked is the lock-free body of AddAlias. The caller must already
|
|
||||||
// hold c.mu (read or write). Split out so the alias-maintenance hooks
|
|
||||||
// invoked from SetProperty / SetMetadataFlat / MergeMetadataFlat can reuse
|
|
||||||
// the work without re-entering the lock — nested RLock can deadlock against
|
|
||||||
// a waiting writer.
|
|
||||||
func (c *Client) addAliasLocked(ctx context.Context, uuid, cidStr string) error {
|
|
||||||
if uuid == "" || cidStr == "" {
|
|
||||||
return fmt.Errorf("uuid and cid are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
keysetField := CIDsKeyPrefix + cidStr
|
|
||||||
keysetKey := c.buildKeyPrefix(uuid) + keysetField
|
|
||||||
|
|
||||||
// Every branch below writes the key-set member, so index the field name
|
|
||||||
// once here — including the two guard branches that skip the alias itself.
|
|
||||||
// Missing it would leave `cids/<cid>` invisible to a record read.
|
|
||||||
defer c.addFieldLocked(ctx, uuid, keysetField)
|
|
||||||
|
|
||||||
// Self-pointing alias guard. If the caller is trying to register
|
|
||||||
// cid:<cid> → <cid> (i.e. the root IS the cid value), that's the
|
|
||||||
// dual-root failure mode in disguise — it means somebody wrote
|
|
||||||
// /meta/<cid> without first resolving via ResolveRoot. Refuse the alias
|
|
||||||
// write; leave any existing alias alone. Still record the key-set
|
|
||||||
// membership so the field-level write succeeds.
|
|
||||||
if uuid == cidStr {
|
|
||||||
return c.client.Set(ctx, keysetKey, "true", 0).Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard against the dual-root pattern: if cid:<cid> already points at a
|
|
||||||
// DIFFERENT uuid, don't silently overwrite. Leave the existing alias and
|
|
||||||
// just record the key-set membership so it's still discoverable via the
|
|
||||||
// root → cids enumeration.
|
|
||||||
indexKey := c.buildCIDIndexKey(cidStr)
|
|
||||||
existing, err := c.client.Get(ctx, indexKey).Result()
|
|
||||||
if err != nil && err != redis.Nil {
|
|
||||||
return fmt.Errorf("read existing alias: %w", err)
|
|
||||||
}
|
|
||||||
if existing != "" && existing != uuid {
|
|
||||||
return c.client.Set(ctx, keysetKey, "true", 0).Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
pipe := c.client.Pipeline()
|
|
||||||
pipe.Set(ctx, indexKey, uuid, 0)
|
|
||||||
pipe.Set(ctx, keysetKey, "true", 0)
|
|
||||||
if _, err := pipe.Exec(ctx); err != nil {
|
|
||||||
return fmt.Errorf("add alias pipeline: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeAliasLocked is the inverse of addAliasLocked: it drops the reverse-index
|
|
||||||
// entry cid:<cid> → <uuid>. The caller must already hold c.mu, and must delete
|
|
||||||
// the cids/<cid> key-set member itself (DeleteProperty does both).
|
|
||||||
//
|
|
||||||
// ⚠ Why this exists. A cids/ member write touches THREE keys — the flat
|
|
||||||
// file:<uuid>/cids/<cid>, the __fields__ index entry, and this reverse alias —
|
|
||||||
// while the delete path only ever removed the first two. A "deleted" CID
|
|
||||||
// therefore still resolved: GET /meta/<cid>, ResolveRoot and GetByCID all kept
|
|
||||||
// answering with the record. The asymmetry is not theoretical. meta-share
|
|
||||||
// published dag-pb roots computed over RAR volumes and par2 files onto six
|
|
||||||
// records on watch.nsl.sh; removing the member was expressible, removing the
|
|
||||||
// claim was not, so the false identity survived its own retraction.
|
|
||||||
//
|
|
||||||
// Guarded, not blind: the alias is removed only when it currently points at
|
|
||||||
// THIS uuid. Two records can legitimately race to claim a cid (see the
|
|
||||||
// dual-root guard in addAliasLocked, which leaves the first writer's alias in
|
|
||||||
// place); deleting a member from the loser must not unpick the winner's index.
|
|
||||||
func (c *Client) removeAliasLocked(ctx context.Context, uuid, cidStr string) error {
|
|
||||||
if uuid == "" || cidStr == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
indexKey := c.buildCIDIndexKey(cidStr)
|
|
||||||
existing, err := c.client.Get(ctx, indexKey).Result()
|
|
||||||
if err == redis.Nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read existing alias: %w", err)
|
|
||||||
}
|
|
||||||
if existing != uuid {
|
|
||||||
// Somebody else owns this alias. Leave it.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return c.client.Del(ctx, indexKey).Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveRoot maps a hash supplied by a caller (a meta-sort write, an editor
|
// ResolveRoot maps a hash supplied by a caller (a meta-sort write, an editor
|
||||||
// lookup, an SSE consumer) to the actual storage root key.
|
// lookup, an SSE consumer) to the actual storage root key.
|
||||||
//
|
//
|
||||||
@@ -210,8 +48,8 @@ func (c *Client) removeAliasLocked(ctx context.Context, uuid, cidStr string) err
|
|||||||
// - hash matches a reverse-index entry → return UUID
|
// - hash matches a reverse-index entry → return UUID
|
||||||
// - otherwise return hash unchanged (legacy / explicit-UUID writes)
|
// - otherwise return hash unchanged (legacy / explicit-UUID writes)
|
||||||
//
|
//
|
||||||
// Errors are converted to "no resolution" — if Redis is down or the lookup
|
// Errors are converted to "no resolution" — if the store is down or the
|
||||||
// fails we let the caller proceed with the bare hash and surface the Redis
|
// lookup fails we let the caller proceed with the bare hash and surface the
|
||||||
// failure on the actual operation.
|
// failure on the actual operation.
|
||||||
func (c *Client) ResolveRoot(hash string) string {
|
func (c *Client) ResolveRoot(hash string) string {
|
||||||
if hash == "" {
|
if hash == "" {
|
||||||
@@ -228,155 +66,35 @@ func (c *Client) ResolveRoot(hash string) string {
|
|||||||
// when the CID is unknown; the empty string is the "not found" signal rather
|
// when the CID is unknown; the empty string is the "not found" signal rather
|
||||||
// than a typed error so callers can branch without errors.As.
|
// than a typed error so callers can branch without errors.As.
|
||||||
func (c *Client) GetByCID(cidStr string) (string, error) {
|
func (c *Client) GetByCID(cidStr string) (string, error) {
|
||||||
c.mu.RLock()
|
s, err := c.kv()
|
||||||
defer c.mu.RUnlock()
|
if err != nil {
|
||||||
|
return "", err
|
||||||
if c.client == nil {
|
|
||||||
return "", fmt.Errorf("not connected")
|
|
||||||
}
|
}
|
||||||
if cidStr == "" {
|
if cidStr == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
uuid, _, err := s.Get(ctx, CIDIndexPrefix+cidStr)
|
||||||
uuid, err := c.client.Get(ctx, c.buildCIDIndexKey(cidStr)).Result()
|
|
||||||
if err == redis.Nil {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("get cid index: %w", err)
|
return "", fmt.Errorf("get cid index: %w", err)
|
||||||
}
|
}
|
||||||
return uuid, nil
|
return uuid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddDuplicatePath records that path is another physical location whose
|
// cidsForRoot returns a root's bare-CID key-set members (cids/ prefix
|
||||||
// content matches uuid. Returns true if path was newly added (not already
|
// stripped).
|
||||||
// in the set). The watcher calls this when it sees a file whose midhash
|
func cidsForRoot(ctx context.Context, r kv.Reader, uuid string) ([]string, error) {
|
||||||
// matches an existing root but at a different filePath.
|
prefix := recordPrefix(uuid) + CIDsKeyPrefix
|
||||||
func (c *Client) AddDuplicatePath(uuid, path string) (bool, error) {
|
rows, err := r.Range(ctx, prefix, "", 0)
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return false, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
if uuid == "" || path == "" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
added, err := c.client.SAdd(ctx, c.buildKeyPrefix(uuid)+DuplicatesField, path).Result()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("sadd duplicates: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
return added > 0, nil
|
out := make([]string, 0, len(rows))
|
||||||
}
|
for _, row := range rows {
|
||||||
|
out = append(out, row.K[len(prefix):])
|
||||||
// cidsForRootLocked returns a root's bare-CID key-set members (with the cids/
|
|
||||||
// prefix stripped). Caller must hold c.mu.
|
|
||||||
//
|
|
||||||
// Reads the field index and filters by prefix; only an un-indexed root falls
|
|
||||||
// back to a SCAN, and then over the narrower cids/ glob. See field_index.go.
|
|
||||||
func (c *Client) cidsForRootLocked(ctx context.Context, uuid string) ([]string, error) {
|
|
||||||
return c.fieldsWithPrefixLocked(ctx, uuid, CIDsKeyPrefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteRoot removes everything keyed off uuid: every file:<uuid>/* property
|
|
||||||
// (including the cids/<cid> key-set), every reverse-index entry for those
|
|
||||||
// CIDs, and the index membership. Idempotent — a uuid with no remaining keys
|
|
||||||
// is a successful no-op.
|
|
||||||
func (c *Client) DeleteRoot(uuid string) error {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
if uuid == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
prefix := c.buildKeyPrefix(uuid)
|
|
||||||
|
|
||||||
// 1. Read the cids key-set first — once we delete file:<uuid>/* we lose
|
|
||||||
// the list of reverse-index entries to clean up.
|
|
||||||
cids, err := c.cidsForRootLocked(ctx, uuid)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Delete every reverse-index entry in one shot.
|
|
||||||
if len(cids) > 0 {
|
|
||||||
keys := make([]string, 0, len(cids))
|
|
||||||
for _, cidStr := range cids {
|
|
||||||
keys = append(keys, c.buildCIDIndexKey(cidStr))
|
|
||||||
}
|
|
||||||
if err := c.client.Del(ctx, keys...).Err(); err != nil {
|
|
||||||
return fmt.Errorf("del cid index: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Scan and delete every file:<uuid>/* key (cids/<cid> keys included).
|
|
||||||
var cursor uint64
|
|
||||||
for {
|
|
||||||
keys, next, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("scan root keys: %w", err)
|
|
||||||
}
|
|
||||||
if len(keys) > 0 {
|
|
||||||
if err := c.client.Del(ctx, keys...).Err(); err != nil {
|
|
||||||
return fmt.Errorf("del root keys: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursor = next
|
|
||||||
if cursor == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Remove from the file index set.
|
|
||||||
if err := c.client.SRem(ctx, c.buildIndexKey(), uuid).Err(); err != nil {
|
|
||||||
return fmt.Errorf("srem index: %w", err)
|
|
||||||
}
|
|
||||||
// Marked so the incremental search-index refresh reads it, finds it gone,
|
|
||||||
// and evicts it — otherwise a deleted root keeps answering searches until
|
|
||||||
// the next full rebuild.
|
|
||||||
c.markDirtyLocked(ctx, uuid)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeAddAliasFromFieldLocked inspects a (field, value) pair written into a
|
|
||||||
// root. If it's a key-set member — field name starts with "cids/" — it
|
|
||||||
// registers the implied CID in the reverse index. Writers emit cids/<cid>
|
|
||||||
// members without knowing about the reverse index; this hook keeps it in
|
|
||||||
// sync transparently.
|
|
||||||
//
|
|
||||||
// Caller must hold c.mu. Errors are intentionally swallowed at the call site
|
|
||||||
// (logged, not returned): an out-of-sync reverse index is recoverable via a
|
|
||||||
// sweep, while a failed property write is not.
|
|
||||||
func (c *Client) maybeAddAliasFromFieldLocked(ctx context.Context, uuid, field, value string) error {
|
|
||||||
cidStr := cidFromKeysetField(field)
|
|
||||||
if cidStr == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return c.addAliasLocked(ctx, uuid, cidStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeAddAliasesFromMetadataLocked bulk-applies maybeAddAliasFromFieldLocked
|
|
||||||
// over every entry in a metadata map. Used by SetMetadataFlat /
|
|
||||||
// MergeMetadataFlat after the underlying pipeline write succeeds. Caller must
|
|
||||||
// hold c.mu.
|
|
||||||
func (c *Client) maybeAddAliasesFromMetadataLocked(ctx context.Context, uuid string, metadata map[string]string) {
|
|
||||||
for k := range metadata {
|
|
||||||
_ = c.maybeAddAliasFromFieldLocked(ctx, uuid, k, metadata[k])
|
|
||||||
}
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cidFromKeysetField returns the bare CID encoded in a cids/<cid> field name,
|
// cidFromKeysetField returns the bare CID encoded in a cids/<cid> field name,
|
||||||
@@ -406,24 +124,23 @@ type MetadataDocument struct {
|
|||||||
// the duplicates SET. Returns nil if the root has no keys at all (deleted or
|
// the duplicates SET. Returns nil if the root has no keys at all (deleted or
|
||||||
// never existed).
|
// never existed).
|
||||||
func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
|
func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
|
||||||
c.mu.RLock()
|
s, err := c.kv()
|
||||||
defer c.mu.RUnlock()
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
if c.client == nil {
|
|
||||||
return nil, fmt.Errorf("not connected")
|
|
||||||
}
|
}
|
||||||
if uuid == "" {
|
if uuid == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
flat, err := c.getMetadataFlatInternal(ctx, uuid)
|
flat, err := readRecord(ctx, s, uuid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
prefix := c.buildKeyPrefix(uuid)
|
if flat == nil {
|
||||||
|
flat = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
// Hoist the cids/<cid> key-set members into a typed array and derive the
|
// Hoist the cids/<cid> key-set members into a typed array and derive the
|
||||||
// canonical CID by rank. The members remain in Flat too — that's how
|
// canonical CID by rank. The members remain in Flat too — that's how
|
||||||
@@ -443,9 +160,16 @@ func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dups, err := c.client.SMembers(ctx, prefix+DuplicatesField).Result()
|
// Duplicates: every other local path holding the same content.
|
||||||
if err != nil && err != redis.Nil {
|
paths, err := localPaths(ctx, s.DB(), cids)
|
||||||
return nil, fmt.Errorf("smembers duplicates: %w", err)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read duplicates: %w", err)
|
||||||
|
}
|
||||||
|
dups := make([]string, 0, len(paths))
|
||||||
|
for _, p := range paths {
|
||||||
|
if p != flat["filePath"] {
|
||||||
|
dups = append(dups, p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(flat) == 0 && len(cids) == 0 && len(dups) == 0 {
|
if len(flat) == 0 && len(cids) == 0 && len(dups) == 0 {
|
||||||
@@ -458,50 +182,3 @@ func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
|
|||||||
Duplicates: dups,
|
Duplicates: dups,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterFileTuple records one physical file under its midhash256 CID, the
|
|
||||||
// same way the file watcher does for every file it scans: mint a UUID root
|
|
||||||
// (filePath/sizeByte/mtimeNano) and write `cids/<midhash>`, whose hook
|
|
||||||
// registers the cid:<midhash> reverse alias. Content already known under
|
|
||||||
// another path is recorded as a duplicate; the same path just refreshes
|
|
||||||
// size/mtime.
|
|
||||||
//
|
|
||||||
// Returns the root and whether it was newly minted. Shared by the watcher and
|
|
||||||
// POST /api/files/register, which is how a box running with the watcher off
|
|
||||||
// (a meta-watch client core) still gets files it writes under /files/plugin
|
|
||||||
// resolvable by CID.
|
|
||||||
func (c *Client) RegisterFileTuple(absPath string, size, mtimeNano int64, midhash string) (string, bool, error) {
|
|
||||||
if midhash == "" {
|
|
||||||
return "", false, fmt.Errorf("midhash is required")
|
|
||||||
}
|
|
||||||
uuid, err := c.GetByCID(midhash)
|
|
||||||
if err != nil {
|
|
||||||
return "", false, fmt.Errorf("GetByCID(%s): %w", midhash, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if uuid == "" {
|
|
||||||
uuid, err = c.Mint(absPath, size, mtimeNano)
|
|
||||||
if err != nil {
|
|
||||||
return "", false, fmt.Errorf("Mint(%s): %w", absPath, err)
|
|
||||||
}
|
|
||||||
if err := c.SetProperty(uuid, CIDsKeyPrefix+midhash, "true"); err != nil {
|
|
||||||
return uuid, true, fmt.Errorf("SetProperty cids/%s for %s: %w", midhash, uuid, err)
|
|
||||||
}
|
|
||||||
return uuid, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
existing, _ := c.GetProperty(uuid, "filePath")
|
|
||||||
if existing != absPath {
|
|
||||||
if _, err := c.AddDuplicatePath(uuid, absPath); err != nil {
|
|
||||||
return uuid, false, fmt.Errorf("AddDuplicatePath %s @ %s: %w", uuid, absPath, err)
|
|
||||||
}
|
|
||||||
return uuid, false, nil
|
|
||||||
}
|
|
||||||
if _, err := c.MergeMetadataFlat(uuid, map[string]string{
|
|
||||||
"sizeByte": strconv.FormatInt(size, 10),
|
|
||||||
"mtimeNano": strconv.FormatInt(mtimeNano, 10),
|
|
||||||
}); err != nil {
|
|
||||||
return uuid, false, fmt.Errorf("refresh tuple for %s: %w", uuid, err)
|
|
||||||
}
|
|
||||||
return uuid, false, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tests for retracting a `cids/<cid>` key-set member (cid_resolution.go's
|
|
||||||
// removeAliasLocked, reached through DeleteProperty).
|
|
||||||
//
|
|
||||||
// Why this is worth its own file. Adding a member writes THREE keys — the flat
|
|
||||||
// `file:<uuid>/cids/<cid>`, the `__fields__` index entry, and the reverse alias
|
|
||||||
// `cid:<cid>` → `<uuid>`. Until removeAliasLocked existed, deleting removed only
|
|
||||||
// the first two, so a retracted CID kept resolving to the record: the member was
|
|
||||||
// gone from the document and the claim was not.
|
|
||||||
//
|
|
||||||
// That asymmetry had a live consequence. meta-share minted dag-pb roots and
|
|
||||||
// midhash256s over whatever `archive::pick_media` elected, which on an
|
|
||||||
// obfuscated posting was an arbitrary RAR volume or a par2 file. Six records on
|
|
||||||
// watch.nsl.sh carry such members. `METADATA_KEYS.md` §2 defines a `cids/`
|
|
||||||
// member as a digest of *this record's file* and notes that every member is a
|
|
||||||
// reverse index back to the record — so those entries assert that an archive
|
|
||||||
// volume is the episode. Retracting one has to remove the assertion, not just
|
|
||||||
// the row.
|
|
||||||
|
|
||||||
// aliasTarget returns the uuid that cid:<cidStr> currently resolves to, or "".
|
|
||||||
func aliasTarget(t *testing.T, c *Client, cidStr string) string {
|
|
||||||
t.Helper()
|
|
||||||
got, err := c.client.Get(context.Background(), c.buildCIDIndexKey(cidStr)).Result()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasField(t *testing.T, c *Client, hashID, field string) bool {
|
|
||||||
t.Helper()
|
|
||||||
ok, err := c.client.SIsMember(context.Background(), c.buildFieldsKey(hashID), field).Result()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sismember: %v", err)
|
|
||||||
}
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// The real shape: `…glnrqztt…` is the nzb-release locator whose record published
|
|
||||||
// a dag-pb root computed over volume 3 of a 35-volume RAR5 set.
|
|
||||||
const (
|
|
||||||
poisonedRoot = "bafybeicicuzdvuztca2hp2g2k7scgr4nnibilzgoppljeprphybqot7v6i"
|
|
||||||
goodMidhash = "bagacbabaed6uiulnv7tscpxne22eztkjvnxg5almn43dpfecppixarfe7xhlu"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRetractingACidMemberRemovesTheClaimNotJustTheRow(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
const uuid = "01KSBHMTESTRECORD"
|
|
||||||
|
|
||||||
if err := c.SetMetadataFlat(uuid, map[string]string{
|
|
||||||
"cids/" + poisonedRoot: "true",
|
|
||||||
"cids/" + goodMidhash: "true",
|
|
||||||
"fileType": "video",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("seed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// All three writes landed, and the CID resolves to the record.
|
|
||||||
if got := aliasTarget(t, c, poisonedRoot); got != uuid {
|
|
||||||
t.Fatalf("alias should point at the record before retraction, got %q", got)
|
|
||||||
}
|
|
||||||
if !hasField(t, c, uuid, "cids/"+poisonedRoot) {
|
|
||||||
t.Fatal("field index should list the member before retraction")
|
|
||||||
}
|
|
||||||
if got := c.ResolveRoot(poisonedRoot); got != uuid {
|
|
||||||
t.Fatalf("ResolveRoot should find the record before retraction, got %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.DeleteProperty(uuid, "cids/"+poisonedRoot); err != nil {
|
|
||||||
t.Fatalf("DeleteProperty: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. the flat key
|
|
||||||
if v, err := c.client.Get(context.Background(),
|
|
||||||
c.buildKeyPrefix(uuid)+"cids/"+poisonedRoot).Result(); err == nil {
|
|
||||||
t.Fatalf("flat key should be gone, still %q", v)
|
|
||||||
}
|
|
||||||
// 2. the field index
|
|
||||||
if hasField(t, c, uuid, "cids/"+poisonedRoot) {
|
|
||||||
t.Fatal("field index still lists the retracted member")
|
|
||||||
}
|
|
||||||
// 3. the reverse alias — the one that used to survive
|
|
||||||
if got := aliasTarget(t, c, poisonedRoot); got != "" {
|
|
||||||
t.Fatalf("reverse alias survived retraction, still points at %q", got)
|
|
||||||
}
|
|
||||||
if got := c.ResolveRoot(poisonedRoot); got == uuid {
|
|
||||||
t.Fatal("a retracted cid must not resolve to the record any more")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The siblings are untouched: retraction is surgical, not a purge.
|
|
||||||
if got := aliasTarget(t, c, goodMidhash); got != uuid {
|
|
||||||
t.Fatalf("an unrelated sibling lost its alias, got %q", got)
|
|
||||||
}
|
|
||||||
if !hasField(t, c, uuid, "cids/"+goodMidhash) {
|
|
||||||
t.Fatal("an unrelated sibling lost its field-index entry")
|
|
||||||
}
|
|
||||||
if v, err := c.GetProperty(uuid, "fileType"); err != nil || v != "video" {
|
|
||||||
t.Fatalf("an unrelated field changed: %q %v", v, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two records can race to claim one cid; addAliasLocked leaves the first
|
|
||||||
// writer's alias in place. Retracting the loser's member must not unpick the
|
|
||||||
// winner's index — which is why removeAliasLocked compares before deleting.
|
|
||||||
func TestRetractingDoesNotStealAnotherRecordsAlias(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
const winner = "01KSBHMWINNER"
|
|
||||||
const loser = "01KSBHMLOSER"
|
|
||||||
const shared = "bafybeiaeb2lqi4z3np3q4tarldtyjp2t5lvnoodmtpvpi2tz73azn5td2m"
|
|
||||||
|
|
||||||
if err := c.SetMetadataFlat(winner, map[string]string{"cids/" + shared: "true"}); err != nil {
|
|
||||||
t.Fatalf("seed winner: %v", err)
|
|
||||||
}
|
|
||||||
if err := c.SetMetadataFlat(loser, map[string]string{"cids/" + shared: "true"}); err != nil {
|
|
||||||
t.Fatalf("seed loser: %v", err)
|
|
||||||
}
|
|
||||||
if got := aliasTarget(t, c, shared); got != winner {
|
|
||||||
t.Fatalf("the first writer should own the alias, got %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.DeleteProperty(loser, "cids/"+shared); err != nil {
|
|
||||||
t.Fatalf("DeleteProperty: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := aliasTarget(t, c, shared); got != winner {
|
|
||||||
t.Fatalf("retracting the loser's member stole the winner's alias, now %q", got)
|
|
||||||
}
|
|
||||||
if hasField(t, c, loser, "cids/"+shared) {
|
|
||||||
t.Fatal("the loser's own member should still be gone")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// A non-cids field has no alias to clean up, and the delete must not invent one.
|
|
||||||
func TestRetractingAnOrdinaryFieldTouchesNoAlias(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
const uuid = "01KSBHMORDINARY"
|
|
||||||
if err := c.SetMetadataFlat(uuid, map[string]string{
|
|
||||||
"cids/" + goodMidhash: "true",
|
|
||||||
"quality": "1080p",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("seed: %v", err)
|
|
||||||
}
|
|
||||||
if err := c.DeleteProperty(uuid, "quality"); err != nil {
|
|
||||||
t.Fatalf("DeleteProperty: %v", err)
|
|
||||||
}
|
|
||||||
if got := aliasTarget(t, c, goodMidhash); got != uuid {
|
|
||||||
t.Fatalf("deleting an ordinary field disturbed a cid alias, got %q", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/claim"
|
||||||
|
"github.com/metazla/meta-core/internal/kv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteClaimStatuses(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
a := signer(t, 1)
|
||||||
|
good := vote(t, a, "bafyA", "title", "The Matrix", 100)
|
||||||
|
tampered := vote(t, a, "bafyA", "year", "1999", 100)
|
||||||
|
tampered.Value = claim.Text("2000")
|
||||||
|
highS := vote(t, a, "bafyA", "studio", "WB", 100)
|
||||||
|
highS.Sig = flipS(highS.Sig)
|
||||||
|
|
||||||
|
got := write(t, c,
|
||||||
|
good,
|
||||||
|
tampered,
|
||||||
|
highS,
|
||||||
|
claim.Claim{CID: "bafyA", UID: a.UID, TS: 1}, // no key
|
||||||
|
vote(t, a, "bafyA", "cid_sha256", "x", 100), // removed CID shape
|
||||||
|
)
|
||||||
|
want := []ClaimStatus{StatusAccepted, StatusBadSignature, StatusBadSignature, StatusBadRequest, StatusBadRequest}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("statuses %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if st := write(t, c, good); st[0] != StatusUnchanged {
|
||||||
|
t.Fatalf("identical re-send: %s, want unchanged", st[0])
|
||||||
|
}
|
||||||
|
if st := write(t, c, vote(t, a, "bafyA", "title", "Older", 50)); st[0] != StatusStale {
|
||||||
|
t.Fatalf("older ts: %s, want stale", st[0])
|
||||||
|
}
|
||||||
|
if st := write(t, c, vote(t, a, "bafyA", "title", "Same ts other value", 100)); st[0] != StatusStale {
|
||||||
|
t.Fatalf("equal ts, different claim: %s, want stale", st[0])
|
||||||
|
}
|
||||||
|
if got := record(t, c, "bafyA")["title"]; got != "The Matrix" {
|
||||||
|
t.Fatalf("title = %q", got)
|
||||||
|
}
|
||||||
|
// A re-persist of the same value with a newer ts keeps the stored row (and
|
||||||
|
// its older ts): it is "unchanged", not a new version.
|
||||||
|
if st := write(t, c, vote(t, a, "bafyA", "title", "The Matrix", 200)); st[0] != StatusUnchanged {
|
||||||
|
t.Fatalf("same value, newer ts: %s, want unchanged", st[0])
|
||||||
|
}
|
||||||
|
// Proof the stored ts stayed 100: a new value at 150 (< 200) is accepted.
|
||||||
|
if st := write(t, c, vote(t, a, "bafyA", "title", "Updated", 150)); st[0] != StatusAccepted {
|
||||||
|
t.Fatalf("new value at ts 150 after a same-value re-send at 200: %s, want accepted", st[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flipS(sig []byte) []byte {
|
||||||
|
// n - s for secp256k1, computed via the vectors' helper in internal/claim
|
||||||
|
// would be cleaner; here big-endian subtraction on bytes is enough.
|
||||||
|
n := []byte{
|
||||||
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe,
|
||||||
|
0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, 0xbf, 0xd2, 0x5e, 0x8c, 0xd0, 0x36, 0x41, 0x41,
|
||||||
|
}
|
||||||
|
out := append([]byte{}, sig...)
|
||||||
|
borrow := 0
|
||||||
|
for i := 31; i >= 0; i-- {
|
||||||
|
d := int(n[i]) - int(sig[32+i]) - borrow
|
||||||
|
borrow = 0
|
||||||
|
if d < 0 {
|
||||||
|
d += 256
|
||||||
|
borrow = 1
|
||||||
|
}
|
||||||
|
out[32+i] = byte(d)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// A retraction is kept as a tombstone, so the vote it retracted cannot be
|
||||||
|
// replayed — by its signer's old copy or by a peer that still holds it.
|
||||||
|
func TestRetractionKeepsTombstoneAndBlocksReplay(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
a := signer(t, 1)
|
||||||
|
old := vote(t, a, "bafyA", "genres/Drama", "true", 10)
|
||||||
|
mustAccept(t, c, old, vote(t, a, "bafyA", "title", "X", 10))
|
||||||
|
mustAccept(t, c, vote(t, a, "bafyA", "genres/Drama", nil, 20))
|
||||||
|
if _, ok := record(t, c, "bafyA")["genres/Drama"]; ok {
|
||||||
|
t.Fatal("retracted member still merged")
|
||||||
|
}
|
||||||
|
if st := write(t, c, old); st[0] != StatusStale {
|
||||||
|
t.Fatalf("replay of the retracted vote: %s, want stale", st[0])
|
||||||
|
}
|
||||||
|
if n, _ := c.CountClaims(); n != 2 {
|
||||||
|
t.Fatalf("claims = %d, want 2 (tombstone kept)", n)
|
||||||
|
}
|
||||||
|
live, _ := c.ClaimsForCIDs([]string{"bafyA"}, "")
|
||||||
|
if len(live) != 1 || live[0].Key != "title" {
|
||||||
|
t.Fatalf("live claims = %+v, want only title", live)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeRule(t *testing.T) {
|
||||||
|
tx := func(s string) claim.Value { return claim.Text(s) }
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
votes []Vote
|
||||||
|
viewer string
|
||||||
|
want string
|
||||||
|
nsrc int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"support wins", []Vote{{Uid: "a", Value: tx("X"), TS: 9}, {Uid: "b", Value: tx("x "), TS: 9}, {Uid: "c", Value: tx("Y"), TS: 1}}, "", "X", 2, true},
|
||||||
|
{"tie: oldest wins", []Vote{{Uid: "a", Value: tx("New"), TS: 9}, {Uid: "b", Value: tx("Old"), TS: 1}}, "", "Old", 1, true},
|
||||||
|
{"tie: smallest wins", []Vote{{Uid: "a", Value: tx("b"), TS: 5}, {Uid: "b", Value: tx("a"), TS: 5}}, "", "a", 1, true},
|
||||||
|
{"signer counts once, newest", []Vote{{Uid: "a", Value: tx("X"), TS: 1}, {Uid: "a", Value: tx("Y"), TS: 2}, {Uid: "b", Value: tx("Y"), TS: 3}}, "", "Y", 2, true},
|
||||||
|
{"retraction drops", []Vote{{Uid: "a", Value: tx("X"), TS: 1}, {Uid: "a", Value: claim.Null(), TS: 2}}, "", "", 0, false},
|
||||||
|
{"ciphertext never merges", []Vote{{Uid: "a", Value: claim.Bytes([]byte{1}), TS: 1}}, "", "", 0, false},
|
||||||
|
{"viewer wins", []Vote{{Uid: "a", Value: tx("X"), TS: 1}, {Uid: "b", Value: tx("X"), TS: 1}, {Uid: "v", Value: tx("Mine"), TS: 9}}, "v", "Mine", 1, true},
|
||||||
|
{"viewer retraction does not hide", []Vote{{Uid: "a", Value: tx("X"), TS: 1}, {Uid: "v", Value: claim.Null(), TS: 9}}, "v", "X", 1, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := Merge(tc.votes, tc.viewer)
|
||||||
|
if got.OK != tc.ok || got.Value != tc.want || (tc.ok && got.Nsrc != tc.nsrc) {
|
||||||
|
t.Errorf("%s: got %+v, want %q nsrc %d ok %v", tc.name, got, tc.want, tc.nsrc, tc.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergedViewAndClaims(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
gwA, gwB, gwC := signer(t, 1), signer(t, 2), signer(t, 3)
|
||||||
|
mustAccept(t, c,
|
||||||
|
vote(t, gwA, "bafyA", "title", "Matrix", 10),
|
||||||
|
vote(t, gwB, "bafyA", "title", "Matrix", 20),
|
||||||
|
vote(t, gwC, "bafyA", "title", "The Matrix (1999)", 5),
|
||||||
|
)
|
||||||
|
rec := record(t, c, "bafyA")
|
||||||
|
if rec["title"] != "Matrix" || rec["cids/bafyA"] != "true" {
|
||||||
|
t.Fatalf("merged %v", rec)
|
||||||
|
}
|
||||||
|
claims, err := c.ClaimsForRID(rid(t, c, "bafyA"), "title")
|
||||||
|
if err != nil || len(claims) != 3 {
|
||||||
|
t.Fatalf("claims %d (err %v), want 3", len(claims), err)
|
||||||
|
}
|
||||||
|
for _, cl := range claims {
|
||||||
|
if err := cl.Verify(); err != nil {
|
||||||
|
t.Fatalf("returned claim does not verify: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cids/* link merges two groups: the older rid survives, the loser's rows
|
||||||
|
// go with `del` events, and the survivor carries both groups' votes.
|
||||||
|
func TestGroupLinkMergesRecords(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
a, b := signer(t, 1), signer(t, 2)
|
||||||
|
mustAccept(t, c, vote(t, a, "bafyM", "title", "Local copy", 10))
|
||||||
|
older := rid(t, c, "bafyM")
|
||||||
|
mustAccept(t, c, vote(t, b, "bafyQ", "year", "1999", 10))
|
||||||
|
newer := rid(t, c, "bafyQ")
|
||||||
|
if older == newer || older > newer {
|
||||||
|
t.Fatalf("expected two groups, older first: %s %s", older, newer)
|
||||||
|
}
|
||||||
|
before, _ := c.Store().LastID(context.Background(), MetaEventsStream)
|
||||||
|
|
||||||
|
mustAccept(t, c, vote(t, a, "bafyM", "cids/bafyQ", "true", 11))
|
||||||
|
if got := rid(t, c, "bafyQ"); got != older {
|
||||||
|
t.Fatalf("bafyQ → %s, want the older rid %s", got, older)
|
||||||
|
}
|
||||||
|
rec := record(t, c, "bafyM")
|
||||||
|
if rec["title"] != "Local copy" || rec["year"] != "1999" || rec["cids/bafyQ"] != "true" || rec["cids/bafyM"] != "true" {
|
||||||
|
t.Fatalf("survivor %v", rec)
|
||||||
|
}
|
||||||
|
if f, _ := c.GetMetadataFlat(newer); f != nil {
|
||||||
|
t.Fatalf("loser still has rows: %v", f)
|
||||||
|
}
|
||||||
|
entries, _ := c.Store().ReadAfter(context.Background(), MetaEventsStream, before, 1000)
|
||||||
|
sawDel := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Values["type"] == "del" && strings.HasPrefix(e.Values["key"], "file:"+newer+"/") {
|
||||||
|
sawDel = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawDel {
|
||||||
|
t.Fatal("no del event for the losing rid")
|
||||||
|
}
|
||||||
|
if n, _ := c.CountRoots(); n != 1 {
|
||||||
|
t.Fatalf("roots = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewerOverlay(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
a, b, me := signer(t, 1), signer(t, 2), signer(t, 9)
|
||||||
|
mustAccept(t, c,
|
||||||
|
vote(t, a, "bafyA", "title", "Shared", 1),
|
||||||
|
vote(t, b, "bafyA", "title", "Shared", 1),
|
||||||
|
vote(t, me, "bafyA", "title", "Mine", 2),
|
||||||
|
vote(t, me, "bafyA", "seek", []byte{1, 2, 3}, 2), // ciphertext: never shown
|
||||||
|
)
|
||||||
|
r := rid(t, c, "bafyA")
|
||||||
|
shared, _ := c.GetMetadataFlat(r)
|
||||||
|
mine, _ := c.GetMetadataFlatAs(r, me.UID)
|
||||||
|
if shared["title"] != "Shared" || mine["title"] != "Mine" {
|
||||||
|
t.Fatalf("shared %q, mine %q", shared["title"], mine["title"])
|
||||||
|
}
|
||||||
|
if _, ok := mine["seek"]; ok {
|
||||||
|
t.Fatal("ciphertext leaked into the view")
|
||||||
|
}
|
||||||
|
match := func(_ string, f map[string]string) bool { return strings.Contains(strings.ToLower(f["title"]), "mine") }
|
||||||
|
if hits, _ := c.Search("", nil, 10, match); len(hits) != 0 {
|
||||||
|
t.Fatalf("shared search found the viewer's value: %v", hits)
|
||||||
|
}
|
||||||
|
hits, err := c.SearchAs("", nil, 10, me.UID, match)
|
||||||
|
if err != nil || len(hits) != 1 {
|
||||||
|
t.Fatalf("viewer search hits %v (err %v), want 1", hits, err)
|
||||||
|
}
|
||||||
|
merged, _, dropped, err := c.MergeClaims([]claim.Claim{
|
||||||
|
vote(t, a, "bafyA", "title", "Shared", 1),
|
||||||
|
vote(t, b, "bafyZ", "title", "Other", 1),
|
||||||
|
}, me.UID)
|
||||||
|
if err != nil || dropped != 0 || merged["title"] != "Mine" {
|
||||||
|
t.Fatalf("stateless merge as viewer = %v dropped %d err %v", merged, dropped, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A crash between the truth commit and the cache apply leaves the cache
|
||||||
|
// behind; the next boot replays the missed changes from truth.
|
||||||
|
func TestCrashBetweenCommitsIsCaughtUp(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
c := openClient(t, dir)
|
||||||
|
a := signer(t, 1)
|
||||||
|
mustAccept(t, c, vote(t, a, "bafyA", "title", "Before", 1))
|
||||||
|
|
||||||
|
// Commit straight to truth, as WriteClaims does, but never apply.
|
||||||
|
lost := vote(t, a, "bafyA", "title", "After", 2)
|
||||||
|
if err := c.truth.Update(context.Background(), func(tx *kv.Tx) error {
|
||||||
|
seq, err := readLastSeq(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pid, err := ensurePrincipal(tx, lost.UID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seq++
|
||||||
|
if _, err := tx.Exec(`UPDATE claim SET value = ?, ts = ?, sig = ?, seq = ? WHERE cid = ? AND key = ? AND pid = ?`,
|
||||||
|
"After", int64(lost.TS), lost.Sig, seq, lost.CID, lost.Key, pid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeLastSeq(tx, seq)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := record(t, c, "bafyA")["title"]; got != "Before" {
|
||||||
|
t.Fatalf("cache applied a change it was never given: %q", got)
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
|
||||||
|
c = openClient(t, dir)
|
||||||
|
defer c.Close()
|
||||||
|
if got := record(t, c, "bafyA")["title"]; got != "After" {
|
||||||
|
t.Fatalf("after catch-up title = %q, want After", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cache is a pure function of truth: deleting it and rebuilding yields
|
||||||
|
// exactly what incremental application produced.
|
||||||
|
func TestRebuildEqualsIncremental(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
c := openClient(t, dir)
|
||||||
|
a, b, u := signer(t, 1), signer(t, 2), signer(t, 3)
|
||||||
|
mustAccept(t, c,
|
||||||
|
vote(t, a, "bafy1", "title", "One", 1), vote(t, a, "bafy1", "genres/Action", "true", 1),
|
||||||
|
vote(t, b, "bafy1", "title", "Uno", 2), vote(t, b, "bafy2", "title", "Two", 2),
|
||||||
|
vote(t, a, "bafy2", "sizeByte", "123", 1), vote(t, u, "bafy2", "like", "true", 3),
|
||||||
|
vote(t, u, "bafy2", "seek", []byte{9}, 3),
|
||||||
|
)
|
||||||
|
mustAccept(t, c,
|
||||||
|
vote(t, a, "bafy1", "genres/Action", nil, 4), // retraction
|
||||||
|
vote(t, b, "bafy1", "cids/bafy2", "true", 5), // group merge
|
||||||
|
vote(t, a, "bafy3", "title", "Three", 5),
|
||||||
|
)
|
||||||
|
incremental := cacheState(t, c)
|
||||||
|
c.Close()
|
||||||
|
for _, f := range []string{"cache.db", "cache.db-wal", "cache.db-shm"} {
|
||||||
|
_ = os.Remove(filepath.Join(dir, f))
|
||||||
|
}
|
||||||
|
c = openClient(t, dir)
|
||||||
|
defer c.Close()
|
||||||
|
if rebuilt := cacheState(t, c); !reflect.DeepEqual(rebuilt, incremental) {
|
||||||
|
t.Fatalf("rebuild differs from incremental:\n incremental %v\n rebuilt %v", incremental, rebuilt)
|
||||||
|
}
|
||||||
|
if c.RebuiltAt() == "" {
|
||||||
|
t.Fatal("rebuild did not record rebuilt_at (SSE consumers would not get a gap)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheState is every derived row that must be a function of truth.
|
||||||
|
func cacheState(t *testing.T, c *Client) []string {
|
||||||
|
t.Helper()
|
||||||
|
var out []string
|
||||||
|
for _, k := range allKeys(t, c) {
|
||||||
|
if strings.HasPrefix(k, "ev:") || strings.HasPrefix(k, "evhwm:") || strings.HasPrefix(k, "meta:") ||
|
||||||
|
strings.HasPrefix(k, ftsNS) || k == countersReady {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, _, _ := c.Store().Get(context.Background(), k)
|
||||||
|
out = append(out, k+"="+v)
|
||||||
|
}
|
||||||
|
rows, err := c.Store().DB().Query(`SELECT pid, cid, key FROM claim_by_pid`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var pid int64
|
||||||
|
var cid, key string
|
||||||
|
_ = rows.Scan(&pid, &cid, &key)
|
||||||
|
out = append(out, "byp:"+strconv.FormatInt(pid, 10)+"/"+cid+"/"+key)
|
||||||
|
}
|
||||||
|
var hays []string
|
||||||
|
hr, _ := c.Store().DB().Query(`SELECT rid, hay FROM fts`)
|
||||||
|
for hr.Next() {
|
||||||
|
var r, h string
|
||||||
|
_ = hr.Scan(&r, &h)
|
||||||
|
hays = append(hays, "fts:"+r+"="+h)
|
||||||
|
}
|
||||||
|
hr.Close()
|
||||||
|
out = append(out, hays...)
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalFileFactsAndEvents(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
before, _ := c.Store().LastID(context.Background(), MetaEventsStream)
|
||||||
|
r, created, err := c.RegisterFileTuple("/files/watch/a.mkv", 1000, 42, "bafkmid")
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("register: %v created=%v", err, created)
|
||||||
|
}
|
||||||
|
rec, _ := c.GetMetadataFlat(r)
|
||||||
|
if rec["filePath"] != "/files/watch/a.mkv" || rec["sizeByte"] != "1000" || rec["mtimeNano"] != "42" || rec["cids/bafkmid"] != "true" {
|
||||||
|
t.Fatalf("record %v", rec)
|
||||||
|
}
|
||||||
|
entries, _ := c.Store().ReadAfter(context.Background(), MetaEventsStream, before, 100)
|
||||||
|
sawPath := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Values["type"] == "set" && e.Values["key"] == "file:"+r+"/filePath" {
|
||||||
|
sawPath = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawPath {
|
||||||
|
t.Fatal("no set event for file:<rid>/filePath (meta-fuse builds its tree from it)")
|
||||||
|
}
|
||||||
|
// A signed sizeByte (content fact) wins over the on-disk size.
|
||||||
|
mustAccept(t, c, vote(t, signer(t, 1), "bafkmid", "sizeByte", "999", 1))
|
||||||
|
if got, _ := c.GetProperty(r, "sizeByte"); got != "999" {
|
||||||
|
t.Fatalf("sizeByte = %q, want the signed 999", got)
|
||||||
|
}
|
||||||
|
// A second path of the same content is a duplicate.
|
||||||
|
if _, _, err := c.RegisterFileTuple("/files/watch/copy.mkv", 1000, 43, "bafkmid"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doc, _ := c.GetMetadataDocument(r)
|
||||||
|
if len(doc.Duplicates) != 1 || doc.Duplicates[0] != "/files/watch/copy.mkv" {
|
||||||
|
t.Fatalf("duplicates %v", doc.Duplicates)
|
||||||
|
}
|
||||||
|
tuples, _ := c.GetTuplesForAllFiles()
|
||||||
|
if len(tuples) != 2 || tuples[0].HashID != r {
|
||||||
|
t.Fatalf("tuples %+v", tuples)
|
||||||
|
}
|
||||||
|
// Deleting the files drops the local facts, keeps the claims.
|
||||||
|
mark, _ := c.Store().LastID(context.Background(), MetaEventsStream)
|
||||||
|
for _, p := range []string{"/files/watch/a.mkv", "/files/watch/copy.mkv"} {
|
||||||
|
if err := c.RemoveFileTuple(p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec, _ = c.GetMetadataFlat(r)
|
||||||
|
if _, ok := rec["filePath"]; ok || rec["sizeByte"] != "999" {
|
||||||
|
t.Fatalf("after delete %v", rec)
|
||||||
|
}
|
||||||
|
entries, _ = c.Store().ReadAfter(context.Background(), MetaEventsStream, mark, 100)
|
||||||
|
sawDel := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Values["type"] == "del" && e.Values["key"] == "file:"+r+"/filePath" {
|
||||||
|
sawDel = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawDel {
|
||||||
|
t.Fatal("no del event for filePath")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemaGateRefusesV1Store(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "meta-core.db")
|
||||||
|
db, err := sql.Open("sqlite", "file:"+path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`CREATE TABLE kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID;
|
||||||
|
INSERT INTO kv VALUES('meta-core:schema-version','2')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
c := NewClient()
|
||||||
|
if err := c.Connect(path, filepath.Join(dir, "cache.db")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.EnsureSchemaVersion(SchemaVersion); err == nil || !strings.Contains(err.Error(), "v1.x") {
|
||||||
|
t.Fatalf("v1 store accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-121
@@ -1,138 +1,75 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/alicebob/miniredis/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// newTestClient spins an in-memory Redis and returns a connected Client with
|
// ClearAllMetadata wipes BOTH databases: votes, signers and groups in truth;
|
||||||
// the given namespace prefix. The server is torn down with the test.
|
// merged records, the cid lookup, roots, search rows, the claim index and the
|
||||||
func newTestClient(t *testing.T, prefix string) (*Client, *miniredis.Miniredis) {
|
// local files in the cache. Service scratch keys are not metadata and survive.
|
||||||
t.Helper()
|
func TestClearAllMetadataWipesTruthAndCache(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
s := miniredis.RunT(t)
|
a := signer(t, 1)
|
||||||
c := NewClient(prefix)
|
mustAccept(t, c,
|
||||||
if err := c.Connect("redis://" + s.Addr()); err != nil {
|
vote(t, a, "bafkone", "title", "One", 1),
|
||||||
t.Fatalf("connect to miniredis: %v", err)
|
vote(t, a, "bafkone", "cids/bafkonefull", "true", 1),
|
||||||
|
vote(t, a, "bafktwo", "title", "Two", 1),
|
||||||
|
)
|
||||||
|
if _, _, err := c.RegisterFileTuple("/files/watch/a.mkv", 10, 20, "bafkmid"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = c.Close() })
|
for k, v := range map[string]string{"btmagnet:abc": "magnet:?xt=urn:btih:abc", "searchcov:tmdb:1": "1"} {
|
||||||
return c, s
|
if err := c.SetServiceValue(k, v); err != nil {
|
||||||
}
|
t.Fatalf("seed %s: %v", k, err)
|
||||||
|
|
||||||
// TestClearAllMetadata_PurgesCIDReverseIndex is the regression test for the
|
|
||||||
// leak: ClearAllMetadata used to delete every file:<root>/* key and the
|
|
||||||
// file:__index__ set, but left the cid:<cid> → root reverse-index entries
|
|
||||||
// behind. Those stale aliases are not cosmetic — ResolveRoot consults them, so
|
|
||||||
// a surviving entry silently redirects a later write into a deleted root.
|
|
||||||
func TestClearAllMetadata_PurgesCIDReverseIndex(t *testing.T) {
|
|
||||||
for _, prefix := range []string{"", "mm:"} {
|
|
||||||
name := "no-prefix"
|
|
||||||
if prefix != "" {
|
|
||||||
name = "prefixed"
|
|
||||||
}
|
}
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
c, s := newTestClient(t, prefix)
|
|
||||||
|
|
||||||
// Two records, each with several CID aliases (a midhash plus
|
|
||||||
// siblings) — exactly what the watcher produces.
|
|
||||||
aliases := map[string][]string{}
|
|
||||||
for i, f := range []string{"watch/a.mkv", "watch/b.mkv"} {
|
|
||||||
uuid, err := c.Mint(f, int64(100+i), int64(1000+i))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("mint: %v", err)
|
|
||||||
}
|
|
||||||
cids := []string{
|
|
||||||
"bafkmid" + uuid,
|
|
||||||
"bafkfull" + uuid,
|
|
||||||
}
|
|
||||||
for _, cidStr := range cids {
|
|
||||||
if err := c.AddAlias(uuid, cidStr); err != nil {
|
|
||||||
t.Fatalf("add alias %s: %v", cidStr, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
aliases[uuid] = cids
|
|
||||||
}
|
|
||||||
|
|
||||||
// An entry already orphaned by a previous (buggy) clear: a
|
|
||||||
// reverse-index key whose root no longer exists. The sweep is
|
|
||||||
// expected to collect it too.
|
|
||||||
if err := s.Set(c.buildCIDIndexKey("bafkorphan"), "01OLDDEADROOT"); err != nil {
|
|
||||||
t.Fatalf("seed orphan: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanity: the aliases resolve before the clear.
|
|
||||||
for uuid, cids := range aliases {
|
|
||||||
for _, cidStr := range cids {
|
|
||||||
if got := c.ResolveRoot(cidStr); got != uuid {
|
|
||||||
t.Fatalf("pre-clear ResolveRoot(%s) = %q, want %q", cidStr, got, uuid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deleted, err := c.ClearAllMetadata()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ClearAllMetadata: %v", err)
|
|
||||||
}
|
|
||||||
// deletedCount counts files, not keys.
|
|
||||||
if deleted != 2 {
|
|
||||||
t.Errorf("deletedCount = %d, want 2 (files, not keys)", deleted)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No reverse-index key may survive — not for the cleared records,
|
|
||||||
// not the pre-existing orphan.
|
|
||||||
if leaked := s.Keys(); len(leaked) != 0 {
|
|
||||||
t.Errorf("keys survived the clear: %v", leaked)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And the aliases must no longer resolve: ResolveRoot returns the
|
|
||||||
// hash unchanged when there is no index entry.
|
|
||||||
for _, cids := range aliases {
|
|
||||||
for _, cidStr := range cids {
|
|
||||||
if got := c.ResolveRoot(cidStr); got != cidStr {
|
|
||||||
t.Errorf("post-clear ResolveRoot(%s) = %q, want the hash unchanged (stale alias)", cidStr, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got, err := c.GetByCID("bafkorphan"); err != nil || got != "" {
|
|
||||||
t.Errorf("post-clear GetByCID(bafkorphan) = %q (err %v), want \"\"", got, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// TestClearAllMetadata_LeavesForeignKeysAlone guards the sweep's blast radius:
|
deleted, err := c.ClearAllMetadata()
|
||||||
// it must key off buildCIDIndexKey (namespace-aware), not a hardcoded "cid:",
|
|
||||||
// so a clear on a prefixed client cannot reach another namespace's index.
|
|
||||||
func TestClearAllMetadata_LeavesForeignKeysAlone(t *testing.T) {
|
|
||||||
c, s := newTestClient(t, "mm:")
|
|
||||||
|
|
||||||
uuid, err := c.Mint("watch/a.mkv", 100, 1000)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mint: %v", err)
|
|
||||||
}
|
|
||||||
if err := c.AddAlias(uuid, "bafkmine"); err != nil {
|
|
||||||
t.Fatalf("add alias: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Another namespace's reverse-index entry, plus an unrelated key.
|
|
||||||
if err := s.Set("other:cid:bafktheirs", "01THEIRROOT"); err != nil {
|
|
||||||
t.Fatalf("seed foreign cid: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.Set("unrelated", "keep-me"); err != nil {
|
|
||||||
t.Fatalf("seed unrelated: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := c.ClearAllMetadata(); err != nil {
|
|
||||||
t.Fatalf("ClearAllMetadata: %v", err)
|
t.Fatalf("ClearAllMetadata: %v", err)
|
||||||
}
|
}
|
||||||
|
if deleted != 3 {
|
||||||
for _, k := range []string{"other:cid:bafktheirs", "unrelated"} {
|
t.Errorf("deleted = %d, want 3 records", deleted)
|
||||||
if !s.Exists(k) {
|
}
|
||||||
t.Errorf("clear deleted foreign key %q — the cid sweep is not namespace-aware", k)
|
for _, k := range allKeys(t, c) {
|
||||||
|
for _, ns := range []string{recordNS, CIDIndexPrefix, rootsNS, ftsNS} {
|
||||||
|
if strings.HasPrefix(k, ns) {
|
||||||
|
t.Errorf("key survived the clear: %q", k)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.Exists(c.buildCIDIndexKey("bafkmine")) {
|
for _, table := range []string{"fts", "claim_by_pid", "local_file"} {
|
||||||
t.Errorf("own reverse-index entry survived the clear")
|
var n int
|
||||||
|
if err := c.Store().DB().QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil || n != 0 {
|
||||||
|
t.Errorf("%s rows after clear = %d (err %v)", table, n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n, err := c.CountClaims(); err != nil || n != 0 {
|
||||||
|
t.Errorf("claims after clear = %d (err %v)", n, err)
|
||||||
|
}
|
||||||
|
if got := c.ResolveRoot("bafkone"); got != "bafkone" {
|
||||||
|
t.Errorf("post-clear ResolveRoot = %q, want the hash unchanged", got)
|
||||||
|
}
|
||||||
|
for _, k := range []string{"btmagnet:abc", "searchcov:tmdb:1"} {
|
||||||
|
if _, _, ok, _ := c.GetKeyInfo(k); !ok {
|
||||||
|
t.Errorf("clear deleted service key %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The store keeps working after a wipe.
|
||||||
|
mustAccept(t, c, vote(t, a, "bafkone", "title", "Again", 1))
|
||||||
|
if got := record(t, c, "bafkone")["title"]; got != "Again" {
|
||||||
|
t.Errorf("title after re-write = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw writes are limited to service scratch keys: merged records and
|
||||||
|
// bookkeeping cannot be written around the claims.
|
||||||
|
func TestServiceValueRefusesRecordKeys(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
for _, k := range []string{"file:R1/title", "cid:bafk", "idx:roots/R1", "ev:meta:events/1", "meta:applied_seq"} {
|
||||||
|
if err := c.SetServiceValue(k, "x"); err == nil {
|
||||||
|
t.Errorf("SetServiceValue(%q) accepted", k)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+654
-1293
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/claim"
|
||||||
|
"github.com/metazla/meta-core/internal/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func schemaJSON(t *testing.T, c *Client) string {
|
||||||
|
t.Helper()
|
||||||
|
snap, err := schema.NewIndexer(c.Store()).Snapshot(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snap.GeneratedAt = snap.GeneratedAt.Truncate(0).UTC()
|
||||||
|
b, _ := json.Marshal(snap.Fields)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The counters behind /api/schema and search's `total` follow the merged view.
|
||||||
|
// Whatever sequence of claims, retractions and edits runs, they must equal what
|
||||||
|
// a full rebuild from the records computes.
|
||||||
|
func TestCountersStayExactAcrossWrites(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
a, b := signer(t, 1), signer(t, 2)
|
||||||
|
mustAccept(t, c,
|
||||||
|
vote(t, a, "bafyR1", "title", "One", 10), vote(t, a, "bafyR1", "sizeByte", "100", 10),
|
||||||
|
vote(t, a, "bafyR1", "title/eng", "One", 10),
|
||||||
|
vote(t, a, "bafyR2", "title", "Two", 10), vote(t, a, "bafyR2", "sizeByte", "x", 10),
|
||||||
|
vote(t, a, "bafyR2", "genres/Action", "true", 10),
|
||||||
|
vote(t, a, "bafyR3", "title", "Three", 10), vote(t, a, "bafyR3", "sizeByte", "300", 10),
|
||||||
|
)
|
||||||
|
mustAccept(t, c,
|
||||||
|
vote(t, a, "bafyR2", "sizeByte", "200", 20), // label string → int
|
||||||
|
vote(t, b, "bafyR1", "title", "One", 20), // agreeing vote: merged value unchanged
|
||||||
|
vote(t, a, "bafyR1", "title/eng", nil, 20), // retraction
|
||||||
|
)
|
||||||
|
if n, err := c.CountRoots(); err != nil || n != 3 {
|
||||||
|
t.Fatalf("CountRoots = %d (err %v), want 3", n, err)
|
||||||
|
}
|
||||||
|
incremental := schemaJSON(t, c)
|
||||||
|
snap, _ := schema.NewIndexer(c.Store()).Snapshot(context.Background())
|
||||||
|
if f := snap.Fields["sizeByte"]; f == nil || f.Type != schema.PrimInt || f.Breakdown["int"] != 3 {
|
||||||
|
t.Fatalf("sizeByte = %+v, want 3 ints", f)
|
||||||
|
}
|
||||||
|
if _, ok := snap.Fields["title/eng"]; ok {
|
||||||
|
t.Fatalf("retracted title/eng still counted: %v", snap.Fields)
|
||||||
|
}
|
||||||
|
if err := c.RebuildCounters(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rebuilt := schemaJSON(t, c); rebuilt != incremental {
|
||||||
|
t.Fatalf("incremental counters drifted from a rebuild:\n incremental %s\n rebuilt %s", incremental, rebuilt)
|
||||||
|
}
|
||||||
|
if n, _ := c.CountRoots(); n != 3 {
|
||||||
|
t.Fatalf("CountRoots after rebuild = %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureCountersBuildsOnce(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
seed(t, c, map[string]map[string]string{"R1": {"title": "One"}})
|
||||||
|
built, err := c.EnsureCounters()
|
||||||
|
if err != nil || !built {
|
||||||
|
t.Fatalf("first EnsureCounters built=%v err=%v", built, err)
|
||||||
|
}
|
||||||
|
if built, _ := c.EnsureCounters(); built {
|
||||||
|
t.Fatal("second EnsureCounters rebuilt again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-persisting the same signed claims writes nothing and publishes nothing —
|
||||||
|
// the gateway's constant repeats.
|
||||||
|
func TestUnchangedClaimWriteIsANoOp(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
a := signer(t, 1)
|
||||||
|
batch := []claim.Claim{
|
||||||
|
vote(t, a, "bafkreiabc", "title", "Naruto", 5),
|
||||||
|
vote(t, a, "bafkreiabc", "fileType", "video", 5),
|
||||||
|
}
|
||||||
|
mustAccept(t, c, batch...)
|
||||||
|
before, _ := c.GetStreamLength(MetaEventsStream)
|
||||||
|
for _, st := range write(t, c, batch...) {
|
||||||
|
if st != StatusUnchanged {
|
||||||
|
t.Fatalf("re-sent claim status %s, want unchanged", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if after, _ := c.GetStreamLength(MetaEventsStream); after != before {
|
||||||
|
t.Fatalf("re-persisting unchanged claims published %d events", after-before)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The one-shot corpus sweep for METADATA_KEYS.md §14.17: the `film` + `tv`
|
|
||||||
// domains merged into `screen`, and the standalone-vs-serial split they were
|
|
||||||
// really carrying moved to the new `workForm` key.
|
|
||||||
//
|
|
||||||
// # Why a sweep and not a read-side alias
|
|
||||||
//
|
|
||||||
// `domain` is **stored, not derived on read** — that is what lets the
|
|
||||||
// `contentKind` vocabulary stay open (a peer that has never heard of a kind
|
|
||||||
// still routes the record correctly). The price of storing it is that a
|
|
||||||
// vocabulary change has to be paid once, in the data: every consumer's filter
|
|
||||||
// is an exact match, so a record left saying `film` is invisible to a
|
|
||||||
// `domain:screen` wall, and teaching every consumer to accept both spellings
|
|
||||||
// forever is exactly the alternation the merge exists to delete.
|
|
||||||
//
|
|
||||||
// So the values are rewritten at rest, here, and no alias vocabulary survives
|
|
||||||
// in query filters. (meta-search additionally normalises on the way into its
|
|
||||||
// index, because a record can still arrive from a peer that has not been swept
|
|
||||||
// — that is a courtesy for un-swept peers, not a second source of truth.)
|
|
||||||
//
|
|
||||||
// # Contract
|
|
||||||
//
|
|
||||||
// Idempotent: a record already carrying `screen` and a `workForm` is skipped,
|
|
||||||
// so the endpoint is safe to call repeatedly and safe to call on a fresh box.
|
|
||||||
// Returns the number of records **changed**, not the number examined.
|
|
||||||
|
|
||||||
// legacyDomainReplacements maps a retired `domain` value to its replacement.
|
|
||||||
var legacyDomainReplacements = map[string]string{
|
|
||||||
"film": "screen",
|
|
||||||
"tv": "screen",
|
|
||||||
}
|
|
||||||
|
|
||||||
// workFormByContentKind is the `contentKind` → `workForm` table from
|
|
||||||
// METADATA_KEYS.md §1.
|
|
||||||
//
|
|
||||||
// ⚠ Mirror of the Rust table in `meta-feeder-sdk::domain` (and of
|
|
||||||
// meta-search's copy). It exists here only to backfill the pre-merge corpus —
|
|
||||||
// meta-core never classifies a record it is given, and nothing else in Go reads
|
|
||||||
// it. If the tables ever disagree, the writers win: this one is a one-shot.
|
|
||||||
var workFormByContentKind = map[string]string{
|
|
||||||
// screen
|
|
||||||
"movie": "standalone",
|
|
||||||
"series": "serial",
|
|
||||||
"episode": "serial",
|
|
||||||
// music — an album is a closed work, not an ongoing run.
|
|
||||||
"track": "standalone",
|
|
||||||
"album": "standalone",
|
|
||||||
"artist": "standalone",
|
|
||||||
"musicVideo": "standalone",
|
|
||||||
"djMix": "standalone",
|
|
||||||
"liveSet": "standalone",
|
|
||||||
// literature
|
|
||||||
"book": "standalone",
|
|
||||||
"audiobook": "standalone",
|
|
||||||
"comic": "serial",
|
|
||||||
"manga": "serial",
|
|
||||||
"magazine": "serial",
|
|
||||||
// science
|
|
||||||
"paper": "standalone",
|
|
||||||
// ⚠ `pack` is deliberately absent: a season pack is `serial`, an album
|
|
||||||
// release `standalone`, and only the writer knows which. A pack that
|
|
||||||
// reaches this sweep without a `workForm` keeps none — inventing one would
|
|
||||||
// be a guess written permanently into the record.
|
|
||||||
}
|
|
||||||
|
|
||||||
// domainScreenPlan is one record's pending rewrite. Empty strings mean "leave
|
|
||||||
// this field alone".
|
|
||||||
type domainScreenPlan struct {
|
|
||||||
hashID string
|
|
||||||
domain string
|
|
||||||
workForm string
|
|
||||||
}
|
|
||||||
|
|
||||||
// MigrateDomainScreen rewrites `domain=film|tv` to `domain=screen` and
|
|
||||||
// backfills `workForm` from `contentKind` across the whole corpus.
|
|
||||||
//
|
|
||||||
// Reads in two batched MGET passes (never a per-record SCAN — a single record
|
|
||||||
// read costs a full keyspace sweep on this store), then writes only the records
|
|
||||||
// that actually change.
|
|
||||||
func (c *Client) MigrateDomainScreen(ctx context.Context) (int, error) {
|
|
||||||
plans, err := c.planDomainScreen(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
// Writes take the client lock themselves, so they happen after the read
|
|
||||||
// phase has released it.
|
|
||||||
fixed := 0
|
|
||||||
for _, p := range plans {
|
|
||||||
if p.domain != "" {
|
|
||||||
if err := c.SetProperty(p.hashID, "domain", p.domain); err != nil {
|
|
||||||
return fixed, fmt.Errorf("set domain on %s: %w", p.hashID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if p.workForm != "" {
|
|
||||||
if err := c.SetProperty(p.hashID, "workForm", p.workForm); err != nil {
|
|
||||||
return fixed, fmt.Errorf("set workForm on %s: %w", p.hashID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fixed++
|
|
||||||
}
|
|
||||||
return fixed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// planDomainScreen is the read half: what would change, without changing it.
|
|
||||||
func (c *Client) planDomainScreen(ctx context.Context) ([]domainScreenPlan, error) {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return nil, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
hashIDs, err := c.client.SMembers(ctx, c.buildIndexKey()).Result()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("smembers index: %w", err)
|
|
||||||
}
|
|
||||||
if len(hashIDs) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Three keys per record, in a fixed order so the MGET response decodes by
|
|
||||||
// position: keys[i*3+n] belongs to hashIDs[i].
|
|
||||||
const fieldsPerFile = 3
|
|
||||||
fields := [fieldsPerFile]string{"domain", "workForm", "contentKind"}
|
|
||||||
keys := make([]string, 0, len(hashIDs)*fieldsPerFile)
|
|
||||||
for _, h := range hashIDs {
|
|
||||||
prefix := c.buildKeyPrefix(h)
|
|
||||||
for _, f := range fields {
|
|
||||||
keys = append(keys, prefix+f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chunked so one MGET never carries the whole corpus.
|
|
||||||
const chunk = 3000
|
|
||||||
values := make([]interface{}, 0, len(keys))
|
|
||||||
for start := 0; start < len(keys); start += chunk {
|
|
||||||
end := start + chunk
|
|
||||||
if end > len(keys) {
|
|
||||||
end = len(keys)
|
|
||||||
}
|
|
||||||
got, err := c.client.MGet(ctx, keys[start:end]...).Result()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("mget: %w", err)
|
|
||||||
}
|
|
||||||
values = append(values, got...)
|
|
||||||
}
|
|
||||||
|
|
||||||
str := func(i int) string {
|
|
||||||
if i >= len(values) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
s, _ := values[i].(string)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
plans := make([]domainScreenPlan, 0)
|
|
||||||
for i, h := range hashIDs {
|
|
||||||
base := i * fieldsPerFile
|
|
||||||
domain, workForm, contentKind := str(base), str(base+1), str(base+2)
|
|
||||||
|
|
||||||
plan := domainScreenPlan{hashID: h}
|
|
||||||
if replacement, ok := legacyDomainReplacements[domain]; ok {
|
|
||||||
plan.domain = replacement
|
|
||||||
}
|
|
||||||
// A stored `workForm` is authoritative — a `pack` writer knew something
|
|
||||||
// the table cannot. Only an absent one is filled in.
|
|
||||||
if workForm == "" {
|
|
||||||
if form, ok := workFormByContentKind[contentKind]; ok {
|
|
||||||
plan.workForm = form
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if plan.domain != "" || plan.workForm != "" {
|
|
||||||
plans = append(plans, plan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return plans, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MigrateDomainScreenWithTimeout is the convenience wrapper used by the API
|
|
||||||
// handler. Generous timeout: the read phase is two batched passes, but the
|
|
||||||
// write phase is one round-trip per changed field.
|
|
||||||
func (c *Client) MigrateDomainScreenWithTimeout() (int, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
return c.MigrateDomainScreen(ctx)
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// seedRecord writes one record's classification fields straight through
|
|
||||||
// SetProperty, which is what a real writer uses (it maintains the field index
|
|
||||||
// and the root index the sweep reads from).
|
|
||||||
func seedRecord(t *testing.T, c *Client, hashID string, fields map[string]string) {
|
|
||||||
t.Helper()
|
|
||||||
for k, v := range fields {
|
|
||||||
if err := c.SetProperty(hashID, k, v); err != nil {
|
|
||||||
t.Fatalf("seed %s/%s: %v", hashID, k, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The merge (METADATA_KEYS.md §14.17) is paid in the data: `domain` is stored,
|
|
||||||
// not derived on read, so a record left saying `film` is invisible to a
|
|
||||||
// `domain:screen` wall. This is the sweep that pays it.
|
|
||||||
func TestMigrateDomainScreen_RewritesLegacyDomainsAndBackfillsWorkForm(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
seedRecord(t, c, "rec-movie", map[string]string{
|
|
||||||
"domain": "film",
|
|
||||||
"contentKind": "movie",
|
|
||||||
})
|
|
||||||
seedRecord(t, c, "rec-episode", map[string]string{
|
|
||||||
"domain": "tv",
|
|
||||||
"contentKind": "episode",
|
|
||||||
})
|
|
||||||
// Already current, and already carries both keys: must be left alone.
|
|
||||||
seedRecord(t, c, "rec-current", map[string]string{
|
|
||||||
"domain": "screen",
|
|
||||||
"contentKind": "series",
|
|
||||||
"workForm": "serial",
|
|
||||||
})
|
|
||||||
// A different domain entirely — the sweep must not touch it, but it is
|
|
||||||
// still owed the `workForm` backfill.
|
|
||||||
seedRecord(t, c, "rec-book", map[string]string{
|
|
||||||
"domain": "literature",
|
|
||||||
"contentKind": "book",
|
|
||||||
})
|
|
||||||
|
|
||||||
fixed, err := c.MigrateDomainScreen(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("migrate: %v", err)
|
|
||||||
}
|
|
||||||
if fixed != 3 {
|
|
||||||
t.Errorf("fixed = %d, want 3 (the two legacy rows + the book's backfill)", fixed)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
hashID string
|
|
||||||
domain string
|
|
||||||
workForm string
|
|
||||||
}{
|
|
||||||
{"rec-movie", "screen", "standalone"},
|
|
||||||
{"rec-episode", "screen", "serial"},
|
|
||||||
{"rec-current", "screen", "serial"},
|
|
||||||
{"rec-book", "literature", "standalone"},
|
|
||||||
} {
|
|
||||||
m, err := c.GetMetadataFlat(tc.hashID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read %s: %v", tc.hashID, err)
|
|
||||||
}
|
|
||||||
if m["domain"] != tc.domain {
|
|
||||||
t.Errorf("%s domain = %q, want %q", tc.hashID, m["domain"], tc.domain)
|
|
||||||
}
|
|
||||||
if m["workForm"] != tc.workForm {
|
|
||||||
t.Errorf("%s workForm = %q, want %q", tc.hashID, m["workForm"], tc.workForm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A second pass must find nothing to do — the endpoint is called by hand on
|
|
||||||
// each box, and "run it twice" must not mean "write the whole corpus twice".
|
|
||||||
func TestMigrateDomainScreen_IsIdempotent(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "mm:")
|
|
||||||
|
|
||||||
seedRecord(t, c, "rec-1", map[string]string{
|
|
||||||
"domain": "tv",
|
|
||||||
"contentKind": "episode",
|
|
||||||
})
|
|
||||||
|
|
||||||
if _, err := c.MigrateDomainScreen(context.Background()); err != nil {
|
|
||||||
t.Fatalf("first pass: %v", err)
|
|
||||||
}
|
|
||||||
fixed, err := c.MigrateDomainScreen(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second pass: %v", err)
|
|
||||||
}
|
|
||||||
if fixed != 0 {
|
|
||||||
t.Errorf("second pass fixed = %d, want 0", fixed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `pack` is the one kind neither routing table can decide — a season pack is
|
|
||||||
// `serial`, an album release `standalone`. A pack that reaches the sweep
|
|
||||||
// without a `workForm` must keep none: inventing one writes a guess into the
|
|
||||||
// record permanently.
|
|
||||||
func TestMigrateDomainScreen_NeverGuessesAPacksWorkForm(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
seedRecord(t, c, "rec-pack", map[string]string{
|
|
||||||
"domain": "tv",
|
|
||||||
"contentKind": "pack",
|
|
||||||
})
|
|
||||||
// A writer that DID know keeps what it wrote.
|
|
||||||
seedRecord(t, c, "rec-album", map[string]string{
|
|
||||||
"domain": "music",
|
|
||||||
"contentKind": "pack",
|
|
||||||
"workForm": "standalone",
|
|
||||||
})
|
|
||||||
|
|
||||||
if _, err := c.MigrateDomainScreen(context.Background()); err != nil {
|
|
||||||
t.Fatalf("migrate: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m, err := c.GetMetadataFlat("rec-pack")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read: %v", err)
|
|
||||||
}
|
|
||||||
if m["domain"] != "screen" {
|
|
||||||
t.Errorf("domain = %q, want screen (the domain WAS knowable)", m["domain"])
|
|
||||||
}
|
|
||||||
if _, ok := m["workForm"]; ok {
|
|
||||||
t.Errorf("workForm = %q, want absent", m["workForm"])
|
|
||||||
}
|
|
||||||
|
|
||||||
a, err := c.GetMetadataFlat("rec-album")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read: %v", err)
|
|
||||||
}
|
|
||||||
if a["workForm"] != "standalone" {
|
|
||||||
t.Errorf("workForm = %q, want standalone (writer-supplied, never overwritten)", a["workForm"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MigrateDualRoots was a one-shot repair for the "dual-root" pattern in the
|
|
||||||
// legacy storage model: /meta/{hash} write handlers that didn't resolve
|
|
||||||
// CID → UUID first created a parallel midhash-rooted entry alongside the
|
|
||||||
// watcher's UUID root.
|
|
||||||
//
|
|
||||||
// That pattern is now structurally impossible:
|
|
||||||
// - writes resolve via ResolveRoot before landing (see handlers), and
|
|
||||||
// - CIDs are stored as a bare-CID key-set (cids/<cid>) keyed off opaque
|
|
||||||
// UUID roots, with the reverse index cid:<cid> → uuid as the single
|
|
||||||
// resolution path.
|
|
||||||
//
|
|
||||||
// The legacy on-disk shape this migration targeted (midhash-rooted entries,
|
|
||||||
// <algo>:<value> tokens, named midhash256 fields) no longer exists, and the
|
|
||||||
// cut-over wipes Redis, so there is nothing to migrate. The function is kept
|
|
||||||
// as a no-op so the admin endpoint and its wrappers continue to compile and
|
|
||||||
// respond.
|
|
||||||
func (c *Client) MigrateDualRoots(ctx context.Context) (int, error) {
|
|
||||||
if c.client == nil {
|
|
||||||
return 0, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MigrateDualRootsWithTimeout is a convenience wrapper used by the API
|
|
||||||
// handler and the startup hook.
|
|
||||||
func (c *Client) MigrateDualRootsWithTimeout() (int, error) {
|
|
||||||
return c.MigrateDualRoots(context.Background())
|
|
||||||
}
|
|
||||||
|
|
||||||
// MigrateDualRootsReport returns a human-readable summary. With the dual-root
|
|
||||||
// pattern retired (see MigrateDualRoots), this always reports nothing to do.
|
|
||||||
func (c *Client) MigrateDualRootsReport(ctx context.Context) (string, int, error) {
|
|
||||||
return "dual-root migration is obsolete under the bare-CID key-set model; nothing to do", 0, nil
|
|
||||||
}
|
|
||||||
@@ -1,486 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The per-record field-name index.
|
|
||||||
//
|
|
||||||
// A record is stored as one flat Redis STRING per leaf
|
|
||||||
// (`file:<uuid>/<field>`), and that shape is deliberate — it is what lets
|
|
||||||
// keyspace notifications fire per *field*, so meta-fuse / meta-editor /
|
|
||||||
// meta-search can react to one field changing instead of diffing a whole hash
|
|
||||||
// (see docs/project-architecture/persistent-storage.md, and
|
|
||||||
// dev/test/suites/flat-keys.bats which asserts records are never hashes).
|
|
||||||
//
|
|
||||||
// The cost of that shape was that reading one record meant asking Redis "which
|
|
||||||
// keys start with `file:<uuid>/`?", and Redis has no prefix index — `SCAN
|
|
||||||
// MATCH` walks the ENTIRE keyspace. On a real store (445k keys, ~36 fields per
|
|
||||||
// record) a single record read cost ~670 SCAN round-trips and ~0.5s of Redis
|
|
||||||
// CPU; SCAN accounted for ~97.5% of all Redis CPU time on the box. That sat on
|
|
||||||
// the byte path for every poster meta-share served, which is how it surfaced.
|
|
||||||
//
|
|
||||||
// So we keep the flat keys and index their *names*:
|
|
||||||
//
|
|
||||||
// file:<uuid>/__fields__ SET{ "filePath", "sizeByte", "cids/bafk…", … }
|
|
||||||
//
|
|
||||||
// A read becomes SMEMBERS + a chunked MGET — two round-trips instead of ~670.
|
|
||||||
// This mirrors `file:__index__` (the set of every root) one level down, and the
|
|
||||||
// bulk reads already worked this way: `GetTuplesForAllFiles` composes its keys
|
|
||||||
// from the index rather than scanning per root.
|
|
||||||
//
|
|
||||||
// **The set is derived, never authoritative.** The flat keys remain the source
|
|
||||||
// of truth. A record whose set is missing (anything written before this landed)
|
|
||||||
// falls back to the old SCAN and populates the set on the way out, so existing
|
|
||||||
// deployments heal themselves on first touch with no migration and no
|
|
||||||
// SchemaVersion bump — which matters, because a bump is a flag day that makes
|
|
||||||
// operators wipe Redis (see schema_version.go).
|
|
||||||
//
|
|
||||||
// The consistency argument that makes a derived index safe here is meta-core's
|
|
||||||
// single-writer rule: leader election publishes an empty `redisUrl` on purpose
|
|
||||||
// (internal/leader/election.go), so nothing outside this process can write a
|
|
||||||
// `file:` key behind the index's back.
|
|
||||||
|
|
||||||
// FieldsField is the reserved field name holding a record's field-name index.
|
|
||||||
//
|
|
||||||
// It is a Redis SET living at `file:<uuid>/__fields__`, i.e. inside the same
|
|
||||||
// prefix as the record's own fields. That keeps every existing sweep correct
|
|
||||||
// for free — `DeleteMetadata`, `DeleteRoot` and `ClearAllMetadata` all
|
|
||||||
// SCAN+DEL `file:<uuid>/*` and therefore drop the index along with the record.
|
|
||||||
// The flip side is that every reader which enumerates that prefix has to skip
|
|
||||||
// it, exactly as they already skip `file:__index__`.
|
|
||||||
const FieldsField = "__fields__"
|
|
||||||
|
|
||||||
// mgetBatch caps how many keys go into one MGET. Redis limits arguments per
|
|
||||||
// command; 1000 is the batch size `GetTuplesForAllFiles` already uses.
|
|
||||||
const mgetBatch = 1000
|
|
||||||
|
|
||||||
// buildFieldsKey constructs the key for a record's field-name index set.
|
|
||||||
func (c *Client) buildFieldsKey(hashID string) string {
|
|
||||||
return c.buildKeyPrefix(hashID) + FieldsField
|
|
||||||
}
|
|
||||||
|
|
||||||
// isReservedFieldName reports whether a field name is bookkeeping rather than
|
|
||||||
// metadata, and so must never be returned to an API caller, exported into a
|
|
||||||
// snapshot, or folded into the search haystack.
|
|
||||||
func isReservedFieldName(field string) bool {
|
|
||||||
return field == FieldsField
|
|
||||||
}
|
|
||||||
|
|
||||||
// readMetadataLocked returns a record's full flat metadata. Caller must hold
|
|
||||||
// c.mu (either mode).
|
|
||||||
//
|
|
||||||
// Fast path: SMEMBERS the field index, then MGET those keys. Fallback: the
|
|
||||||
// legacy full-keyspace SCAN, whose result is used to populate the index so the
|
|
||||||
// next read takes the fast path.
|
|
||||||
//
|
|
||||||
// Returns (nil, nil) for a record with no fields — callers treat that as 404.
|
|
||||||
func (c *Client) readMetadataLocked(ctx context.Context, hashID string) (map[string]string, error) {
|
|
||||||
fields, err := c.client.SMembers(ctx, c.buildFieldsKey(hashID)).Result()
|
|
||||||
if err != nil && err != redis.Nil {
|
|
||||||
return nil, fmt.Errorf("smembers fields: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(fields) > 0 {
|
|
||||||
return c.mgetFieldsLocked(ctx, hashID, fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No index yet: a record written before the index existed, or one that
|
|
||||||
// genuinely has no fields. Fall back to the scan, then backfill.
|
|
||||||
result, scanned, err := c.scanMetadataLocked(ctx, hashID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(scanned) > 0 {
|
|
||||||
// Best-effort: a failed backfill costs the next read a scan, it does
|
|
||||||
// not make the read wrong. Never fail the caller's read for it.
|
|
||||||
if err := c.client.SAdd(ctx, c.buildFieldsKey(hashID), toAny(scanned)...).Err(); err != nil {
|
|
||||||
logFieldIndexBackfillFailure(hashID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(result) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mgetFieldsLocked reads the named fields of a record in chunked MGETs.
|
|
||||||
//
|
|
||||||
// A field named in the index whose key is gone reads back as nil and is simply
|
|
||||||
// skipped: a *stale* index entry is harmless. (A *missing* one would silently
|
|
||||||
// drop data, which is why every write path maintains the set.)
|
|
||||||
func (c *Client) mgetFieldsLocked(ctx context.Context, hashID string, fields []string) (map[string]string, error) {
|
|
||||||
prefix := c.buildKeyPrefix(hashID)
|
|
||||||
|
|
||||||
// Keep the reserved name out of the key list AND out of the result — it is
|
|
||||||
// a SET, so MGET would error on it in real Redis rather than return nil.
|
|
||||||
wanted := make([]string, 0, len(fields))
|
|
||||||
for _, f := range fields {
|
|
||||||
if !isReservedFieldName(f) {
|
|
||||||
wanted = append(wanted, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(wanted) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make(map[string]string, len(wanted))
|
|
||||||
for start := 0; start < len(wanted); start += mgetBatch {
|
|
||||||
end := start + mgetBatch
|
|
||||||
if end > len(wanted) {
|
|
||||||
end = len(wanted)
|
|
||||||
}
|
|
||||||
chunk := wanted[start:end]
|
|
||||||
keys := make([]string, len(chunk))
|
|
||||||
for i, f := range chunk {
|
|
||||||
keys[i] = prefix + f
|
|
||||||
}
|
|
||||||
values, err := c.client.MGet(ctx, keys...).Result()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("mget failed: %w", err)
|
|
||||||
}
|
|
||||||
for i, v := range values {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
result[chunk[i]] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanMetadataLocked is the pre-index read: SCAN the record's key prefix, then
|
|
||||||
// MGET. Retained as the fallback for un-indexed records and as the source for
|
|
||||||
// the backfill. Returns the metadata plus the field names it saw (which
|
|
||||||
// includes fields whose value came back nil, so the index stays complete).
|
|
||||||
func (c *Client) scanMetadataLocked(ctx context.Context, hashID string) (map[string]string, []string, error) {
|
|
||||||
prefix := c.buildKeyPrefix(hashID)
|
|
||||||
result := make(map[string]string)
|
|
||||||
var fields []string
|
|
||||||
|
|
||||||
var cursor uint64
|
|
||||||
for {
|
|
||||||
keys, nextCursor, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("scan failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(keys) > 0 {
|
|
||||||
wanted := make([]string, 0, len(keys))
|
|
||||||
for _, key := range keys {
|
|
||||||
field := strings.TrimPrefix(key, prefix)
|
|
||||||
if isReservedFieldName(field) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fields = append(fields, field)
|
|
||||||
wanted = append(wanted, key)
|
|
||||||
}
|
|
||||||
if len(wanted) > 0 {
|
|
||||||
values, err := c.client.MGet(ctx, wanted...).Result()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("mget failed: %w", err)
|
|
||||||
}
|
|
||||||
for i, key := range wanted {
|
|
||||||
if s, ok := values[i].(string); ok {
|
|
||||||
result[strings.TrimPrefix(key, prefix)] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cursor = nextCursor
|
|
||||||
if cursor == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, fields, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// addFieldsToPipe records field names in the index as part of an existing
|
|
||||||
// pipeline, so a multi-field write stays one round-trip.
|
|
||||||
func (c *Client) addFieldsToPipe(ctx context.Context, pipe redis.Pipeliner, hashID string, fields []string) {
|
|
||||||
names := filterReserved(fields)
|
|
||||||
if len(names) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pipe.SAdd(ctx, c.buildFieldsKey(hashID), toAny(names)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addFieldLocked records a single field name in the index. Caller must hold
|
|
||||||
// c.mu. Best-effort by the same argument as the backfill: the flat key is the
|
|
||||||
// source of truth, and a lost index entry is repaired by the next scan
|
|
||||||
// fallback — so it must not fail the write it accompanies.
|
|
||||||
func (c *Client) addFieldLocked(ctx context.Context, hashID, field string) {
|
|
||||||
if isReservedFieldName(field) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := c.client.SAdd(ctx, c.buildFieldsKey(hashID), field).Err(); err != nil {
|
|
||||||
logFieldIndexWriteFailure(hashID, field, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeFieldLocked drops a field name from the index. Caller must hold c.mu.
|
|
||||||
//
|
|
||||||
// A stale entry left behind by a failure is harmless (the MGET reads nil and
|
|
||||||
// skips it), so this is best-effort too.
|
|
||||||
func (c *Client) removeFieldLocked(ctx context.Context, hashID, field string) {
|
|
||||||
if isReservedFieldName(field) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := c.client.SRem(ctx, c.buildFieldsKey(hashID), field).Err(); err != nil {
|
|
||||||
logFieldIndexWriteFailure(hashID, field, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fieldsWithPrefixLocked returns the record's indexed field names under a
|
|
||||||
// field-name prefix (e.g. "cids/"), falling back to a SCAN when the record has
|
|
||||||
// no index yet. Caller must hold c.mu.
|
|
||||||
func (c *Client) fieldsWithPrefixLocked(ctx context.Context, hashID, fieldPrefix string) ([]string, error) {
|
|
||||||
fields, err := c.client.SMembers(ctx, c.buildFieldsKey(hashID)).Result()
|
|
||||||
if err != nil && err != redis.Nil {
|
|
||||||
return nil, fmt.Errorf("smembers fields: %w", err)
|
|
||||||
}
|
|
||||||
if len(fields) == 0 {
|
|
||||||
// Un-indexed record: scan the narrower `<prefix><fieldPrefix>*` glob
|
|
||||||
// rather than the whole record, and do not backfill from it — a
|
|
||||||
// partial view must never be mistaken for a complete index.
|
|
||||||
return c.scanFieldsWithPrefixLocked(ctx, hashID, fieldPrefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
var out []string
|
|
||||||
for _, f := range fields {
|
|
||||||
if isReservedFieldName(f) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(f, fieldPrefix) {
|
|
||||||
out = append(out, strings.TrimPrefix(f, fieldPrefix))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// SMEMBERS order is arbitrary and SCAN order was too, so the CID array in
|
|
||||||
// GetMetadataDocument used to shuffle between calls. Sorting costs nothing
|
|
||||||
// at these sizes and makes the response byte-stable, which matters for
|
|
||||||
// anything diffing or caching it.
|
|
||||||
sort.Strings(out)
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) scanFieldsWithPrefixLocked(ctx context.Context, hashID, fieldPrefix string) ([]string, error) {
|
|
||||||
prefix := c.buildKeyPrefix(hashID) + fieldPrefix
|
|
||||||
var out []string
|
|
||||||
var cursor uint64
|
|
||||||
for {
|
|
||||||
keys, next, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scan fields: %w", err)
|
|
||||||
}
|
|
||||||
for _, k := range keys {
|
|
||||||
out = append(out, strings.TrimPrefix(k, prefix))
|
|
||||||
}
|
|
||||||
cursor = next
|
|
||||||
if cursor == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WarmFieldIndexes builds every missing field index in a SINGLE keyspace pass.
|
|
||||||
//
|
|
||||||
// Without it the indexes still appear — each record's first read falls back to
|
|
||||||
// a scan and backfills — but that transition is O(records x keyspace) and it
|
|
||||||
// runs again from scratch after any wipe. Measured on the real store (12,345
|
|
||||||
// records / 445k keys) the lazy path warmed at ~1.3 records/s: a ~2.6 HOUR tail
|
|
||||||
// during which meta-core stays pinned exactly as it was before the fix. Worse,
|
|
||||||
// `RepublishMetadata` walks every record at startup, so that tail is paid on
|
|
||||||
// every restart.
|
|
||||||
//
|
|
||||||
// One bulk `file:*` scan grouped by record collapses it to a few seconds. This
|
|
||||||
// is the same shape `GetSearchIndexTuples` already uses for the bulk read, and
|
|
||||||
// for the same reason.
|
|
||||||
//
|
|
||||||
// Idempotent, and safe to run against a partially-indexed store: SADD of a
|
|
||||||
// member that is already there is a no-op. Returns the number of records it
|
|
||||||
// wrote an index for.
|
|
||||||
func (c *Client) WarmFieldIndexes() (int, error) {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return 0, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
filePrefix := c.buildKey("file:") // {filePrefix}{hashID}/{field}
|
|
||||||
indexKey := c.buildIndexKey()
|
|
||||||
|
|
||||||
byRecord := make(map[string][]string)
|
|
||||||
var cursor uint64
|
|
||||||
for {
|
|
||||||
keys, next, err := c.client.Scan(ctx, cursor, filePrefix+"*", 1000).Result()
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("scan: %w", err)
|
|
||||||
}
|
|
||||||
for _, k := range keys {
|
|
||||||
if k == indexKey {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// hashID has no '/'; field may contain them.
|
|
||||||
rest := strings.TrimPrefix(k, filePrefix)
|
|
||||||
slash := strings.IndexByte(rest, '/')
|
|
||||||
if slash < 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hashID, field := rest[:slash], rest[slash+1:]
|
|
||||||
if isReservedFieldName(field) {
|
|
||||||
// Already indexed — remember the record so we don't treat it
|
|
||||||
// as missing, but there is nothing to add for this key.
|
|
||||||
if _, seen := byRecord[hashID]; !seen {
|
|
||||||
byRecord[hashID] = nil
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
byRecord[hashID] = append(byRecord[hashID], field)
|
|
||||||
}
|
|
||||||
cursor = next
|
|
||||||
if cursor == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
written := 0
|
|
||||||
pipe := c.client.Pipeline()
|
|
||||||
pending := 0
|
|
||||||
for hashID, fields := range byRecord {
|
|
||||||
if len(fields) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pipe.SAdd(ctx, c.buildFieldsKey(hashID), toAny(fields)...)
|
|
||||||
written++
|
|
||||||
pending++
|
|
||||||
// Bound the pipeline so a large store doesn't buffer every command.
|
|
||||||
if pending >= 500 {
|
|
||||||
if _, err := pipe.Exec(ctx); err != nil {
|
|
||||||
return written, fmt.Errorf("sadd pipeline: %w", err)
|
|
||||||
}
|
|
||||||
pipe = c.client.Pipeline()
|
|
||||||
pending = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pending > 0 {
|
|
||||||
if _, err := pipe.Exec(ctx); err != nil {
|
|
||||||
return written, fmt.Errorf("sadd pipeline: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return written, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordHasFieldsLocked reports whether a record still has at least one
|
|
||||||
// property key. Caller must hold c.mu.
|
|
||||||
//
|
|
||||||
// `__fields__` must not count — it lives under the record's own prefix, so a
|
|
||||||
// naive "does any `file:<id>/*` key exist?" probe would answer yes for a record
|
|
||||||
// whose every property has been deleted, and the root could never be
|
|
||||||
// de-indexed.
|
|
||||||
func (c *Client) recordHasFieldsLocked(ctx context.Context, hashID string) (bool, error) {
|
|
||||||
n, err := c.client.SCard(ctx, c.buildFieldsKey(hashID)).Result()
|
|
||||||
if err != nil && err != redis.Nil {
|
|
||||||
return false, fmt.Errorf("scard fields: %w", err)
|
|
||||||
}
|
|
||||||
if n > 0 {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Either genuinely empty, or an un-indexed record from before the index
|
|
||||||
// existed — the scan is the only way to tell those apart.
|
|
||||||
prefix := c.buildKeyPrefix(hashID)
|
|
||||||
var cursor uint64
|
|
||||||
for {
|
|
||||||
keys, next, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("scan failed: %w", err)
|
|
||||||
}
|
|
||||||
for _, k := range keys {
|
|
||||||
if !isReservedFieldName(strings.TrimPrefix(k, prefix)) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursor = next
|
|
||||||
if cursor == 0 {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitRecordKey decomposes a full Redis key into (hashID, field) when it is a
|
|
||||||
// record property key. Reports ok=false for anything else — the index set
|
|
||||||
// itself, `file:__index__`, and any key outside the `file:` namespace.
|
|
||||||
//
|
|
||||||
// This is what lets the raw KV escape hatch (SetRawValue / DeleteRawKey) keep
|
|
||||||
// the index honest without its callers knowing the layout.
|
|
||||||
func (c *Client) splitRecordKey(key string) (hashID, field string, ok bool) {
|
|
||||||
rest := key
|
|
||||||
if c.prefix != "" {
|
|
||||||
var found bool
|
|
||||||
rest, found = strings.CutPrefix(rest, c.prefix)
|
|
||||||
if !found {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rest, found := strings.CutPrefix(rest, "file:")
|
|
||||||
if !found {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
hashID, field, found = strings.Cut(rest, "/")
|
|
||||||
if !found || hashID == "" || field == "" {
|
|
||||||
// `file:__index__` has no slash and lands here.
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
if isReservedFieldName(field) {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
return hashID, field, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// The index is derived and self-healing, so a write failure degrades
|
|
||||||
// performance (the next read falls back to a scan and re-populates) rather
|
|
||||||
// than correctness. Log it rather than failing the caller's write — but log it
|
|
||||||
// loudly, because a *persistent* failure means every read is scanning again.
|
|
||||||
func logFieldIndexWriteFailure(hashID, field string, err error) {
|
|
||||||
log.Printf("[Storage] field index update failed for %s/%s: %v (reads fall back to SCAN)", hashID, field, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func logFieldIndexBackfillFailure(hashID string, err error) {
|
|
||||||
log.Printf("[Storage] field index backfill failed for %s: %v (reads keep falling back to SCAN)", hashID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func filterReserved(fields []string) []string {
|
|
||||||
out := make([]string, 0, len(fields))
|
|
||||||
for _, f := range fields {
|
|
||||||
if !isReservedFieldName(f) {
|
|
||||||
out = append(out, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func toAny(ss []string) []interface{} {
|
|
||||||
out := make([]interface{}, len(ss))
|
|
||||||
for i, s := range ss {
|
|
||||||
out[i] = s
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"reflect"
|
|
||||||
"sort"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tests for the per-record field-name index (field_index.go).
|
|
||||||
//
|
|
||||||
// The index is what makes a record read O(fields) instead of O(keyspace). It is
|
|
||||||
// derived state, so the properties worth pinning are: (1) a read served from
|
|
||||||
// the index returns exactly what a scan would have, (2) a record with no index
|
|
||||||
// still reads correctly and gets one, and (3) every write path keeps it in
|
|
||||||
// sync — because a *missing* entry silently drops a field from every
|
|
||||||
// subsequent read, which is the one failure mode that would be invisible.
|
|
||||||
//
|
|
||||||
// Every case runs with and without a namespace prefix, matching clear_test.go.
|
|
||||||
|
|
||||||
func forEachPrefix(t *testing.T, fn func(t *testing.T, c *Client)) {
|
|
||||||
t.Helper()
|
|
||||||
for _, prefix := range []string{"", "mm:"} {
|
|
||||||
name := "no-prefix"
|
|
||||||
if prefix != "" {
|
|
||||||
name = "prefix-" + prefix
|
|
||||||
}
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, prefix)
|
|
||||||
fn(t, c)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fieldNames(t *testing.T, c *Client, hashID string) []string {
|
|
||||||
t.Helper()
|
|
||||||
got, err := c.client.SMembers(context.Background(), c.buildFieldsKey(hashID)).Result()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("smembers fields: %v", err)
|
|
||||||
}
|
|
||||||
sort.Strings(got)
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustGetMetadata(t *testing.T, c *Client, hashID string) map[string]string {
|
|
||||||
t.Helper()
|
|
||||||
got, err := c.GetMetadataFlat(hashID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetMetadataFlat: %v", err)
|
|
||||||
}
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMetadataFlat must index every field it writes, and the read must come back
|
|
||||||
// through the index rather than a scan.
|
|
||||||
func TestFieldIndex_SetMetadataFlatIndexesFields(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
want := map[string]string{
|
|
||||||
"filePath": "/files/watch/a.mkv",
|
|
||||||
"sizeByte": "123",
|
|
||||||
"cids/bafkreiaaa": "true",
|
|
||||||
"titles/jpn/ナルト": "true",
|
|
||||||
"categories/TV/UHD": "true",
|
|
||||||
"info/files/0/path": "a.mkv",
|
|
||||||
"provenance": `{"source":"gateway"}`,
|
|
||||||
"plot/eng": "a plot with / slashes",
|
|
||||||
"stream/3": "eng",
|
|
||||||
"genres/action": "true",
|
|
||||||
"originaltitle": "Naruto",
|
|
||||||
"deeply/nested/field/name/here": "x",
|
|
||||||
}
|
|
||||||
if err := c.SetMetadataFlat("root1", want); err != nil {
|
|
||||||
t.Fatalf("SetMetadataFlat: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := mustGetMetadata(t, c, "root1"); !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("GetMetadataFlat mismatch\n got %v\nwant %v", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The index must name every field — including the ones containing '/',
|
|
||||||
// which is where a naive "split on the first slash" would go wrong.
|
|
||||||
wantNames := make([]string, 0, len(want))
|
|
||||||
for k := range want {
|
|
||||||
wantNames = append(wantNames, k)
|
|
||||||
}
|
|
||||||
sort.Strings(wantNames)
|
|
||||||
if got := fieldNames(t, c, "root1"); !reflect.DeepEqual(got, wantNames) {
|
|
||||||
t.Errorf("field index mismatch\n got %v\nwant %v", got, wantNames)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// A record written before the index existed has flat keys but no set. The read
|
|
||||||
// must still be correct, and must leave an index behind so the next read takes
|
|
||||||
// the fast path. This is the migration path for the ~12k records already on
|
|
||||||
// disk: no flag day, no SchemaVersion bump.
|
|
||||||
func TestFieldIndex_LazyBackfillForUnindexedRecord(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
ctx := context.Background()
|
|
||||||
prefix := c.buildKeyPrefix("legacy")
|
|
||||||
want := map[string]string{
|
|
||||||
"filePath": "/files/watch/old.mkv",
|
|
||||||
"sizeByte": "42",
|
|
||||||
"cids/bafkreiold": "true",
|
|
||||||
}
|
|
||||||
// Write the flat keys directly, bypassing every index-maintaining path
|
|
||||||
// — exactly the shape a pre-upgrade store is in.
|
|
||||||
for f, v := range want {
|
|
||||||
if err := c.client.Set(ctx, prefix+f, v, 0).Err(); err != nil {
|
|
||||||
t.Fatalf("seed %s: %v", f, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if names := fieldNames(t, c, "legacy"); len(names) != 0 {
|
|
||||||
t.Fatalf("precondition: expected no index, got %v", names)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := mustGetMetadata(t, c, "legacy"); !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("scan-fallback read mismatch\n got %v\nwant %v", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantNames := []string{"cids/bafkreiold", "filePath", "sizeByte"}
|
|
||||||
if got := fieldNames(t, c, "legacy"); !reflect.DeepEqual(got, wantNames) {
|
|
||||||
t.Errorf("backfill mismatch\n got %v\nwant %v", got, wantNames)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second read, now served from the index, must agree with the first.
|
|
||||||
if got := mustGetMetadata(t, c, "legacy"); !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("indexed read disagrees with scan read\n got %v\nwant %v", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each single-field write path must add to the index, and each delete path must
|
|
||||||
// remove from it. A field written but not indexed vanishes from every later
|
|
||||||
// read — silently — so this is the property that matters most.
|
|
||||||
func TestFieldIndex_SingleFieldWritePathsStayInSync(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
if err := c.SetProperty("root2", "title", "Naruto"); err != nil {
|
|
||||||
t.Fatalf("SetProperty: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := c.AddToSet("root2", "tags", "anime"); err != nil {
|
|
||||||
t.Fatalf("AddToSet: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := c.MergeMetadataFlat("root2", map[string]string{"year": "2002"}); err != nil {
|
|
||||||
t.Fatalf("MergeMetadataFlat: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := map[string]string{"title": "Naruto", "tags": "anime", "year": "2002"}
|
|
||||||
if got := mustGetMetadata(t, c, "root2"); !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("after writes\n got %v\nwant %v", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteProperty drops the key and the index entry.
|
|
||||||
if err := c.DeleteProperty("root2", "title"); err != nil {
|
|
||||||
t.Fatalf("DeleteProperty: %v", err)
|
|
||||||
}
|
|
||||||
// RemoveFromSet deletes the key once its last member is gone.
|
|
||||||
if _, err := c.RemoveFromSet("root2", "tags", "anime"); err != nil {
|
|
||||||
t.Fatalf("RemoveFromSet: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = map[string]string{"year": "2002"}
|
|
||||||
if got := mustGetMetadata(t, c, "root2"); !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("after deletes\n got %v\nwant %v", got, want)
|
|
||||||
}
|
|
||||||
if got := fieldNames(t, c, "root2"); !reflect.DeepEqual(got, []string{"year"}) {
|
|
||||||
t.Errorf("index after deletes = %v, want [year]", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// The raw KV editor can mint or drop a record property out of band. It is the
|
|
||||||
// one write path whose caller does not know the record layout, so the storage
|
|
||||||
// layer has to keep the index honest for it.
|
|
||||||
func TestFieldIndex_RawKeyEditorStaysInSync(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
if err := c.SetRawValue("file:root3/hand-written", "v"); err != nil {
|
|
||||||
t.Fatalf("SetRawValue: %v", err)
|
|
||||||
}
|
|
||||||
if got := fieldNames(t, c, "root3"); !reflect.DeepEqual(got, []string{"hand-written"}) {
|
|
||||||
t.Errorf("index after SetRawValue = %v", got)
|
|
||||||
}
|
|
||||||
if got := mustGetMetadata(t, c, "root3"); !reflect.DeepEqual(got, map[string]string{"hand-written": "v"}) {
|
|
||||||
t.Errorf("read after SetRawValue = %v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := c.DeleteRawKey("file:root3/hand-written"); err != nil {
|
|
||||||
t.Fatalf("DeleteRawKey: %v", err)
|
|
||||||
}
|
|
||||||
if got := fieldNames(t, c, "root3"); len(got) != 0 {
|
|
||||||
t.Errorf("index after DeleteRawKey = %v, want empty", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keys outside the record namespace must be left alone entirely.
|
|
||||||
if err := c.SetRawValue("cid:bafkreiaaa", "root3"); err != nil {
|
|
||||||
t.Fatalf("SetRawValue non-record: %v", err)
|
|
||||||
}
|
|
||||||
if _, _, ok := c.splitRecordKey(c.buildKey("cid:bafkreiaaa")); ok {
|
|
||||||
t.Error("cid: key was treated as a record property")
|
|
||||||
}
|
|
||||||
if _, _, ok := c.splitRecordKey(c.buildIndexKey()); ok {
|
|
||||||
t.Error("file:__index__ was treated as a record property")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mint and AddAlias write fields through their own pipelines, so they need the
|
|
||||||
// index too. AddAlias in particular is how `cids/<cid>` members appear — miss
|
|
||||||
// it and a record's CID key-set becomes invisible to every read.
|
|
||||||
func TestFieldIndex_MintAndAliasIndexTheirFields(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
uuid, err := c.Mint("/files/watch/x.mkv", 10, 20)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Mint: %v", err)
|
|
||||||
}
|
|
||||||
if got := fieldNames(t, c, uuid); !reflect.DeepEqual(got, []string{"filePath", "mtimeNano", "sizeByte"}) {
|
|
||||||
t.Errorf("index after Mint = %v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.AddAlias(uuid, "bafkreiaaa"); err != nil {
|
|
||||||
t.Fatalf("AddAlias: %v", err)
|
|
||||||
}
|
|
||||||
got := mustGetMetadata(t, c, uuid)
|
|
||||||
if got["cids/bafkreiaaa"] != "true" {
|
|
||||||
t.Errorf("alias key-set member missing from read: %v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And the CID enumeration, which now reads the index instead of
|
|
||||||
// scanning, must see it.
|
|
||||||
cids, err := c.cidsForRootLocked(context.Background(), uuid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("cidsForRootLocked: %v", err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(cids, []string{"bafkreiaaa"}) {
|
|
||||||
t.Errorf("cidsForRoot = %v, want [bafkreiaaa]", cids)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// The index lives under the record's own prefix, so the "is this record empty?"
|
|
||||||
// probe would otherwise always find it and the root could never be de-indexed.
|
|
||||||
func TestFieldIndex_IndexRemoveIfEmptyIgnoresTheIndexItself(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
if err := c.SetProperty("root4", "title", "x"); err != nil {
|
|
||||||
t.Fatalf("SetProperty: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
removed, err := c.IndexRemoveIfEmpty("root4")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("IndexRemoveIfEmpty: %v", err)
|
|
||||||
}
|
|
||||||
if removed {
|
|
||||||
t.Error("de-indexed a record that still has a field")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.DeleteProperty("root4", "title"); err != nil {
|
|
||||||
t.Fatalf("DeleteProperty: %v", err)
|
|
||||||
}
|
|
||||||
removed, err = c.IndexRemoveIfEmpty("root4")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("IndexRemoveIfEmpty: %v", err)
|
|
||||||
}
|
|
||||||
if !removed {
|
|
||||||
t.Fatal("record with no fields left was not de-indexed — the field index masked emptiness")
|
|
||||||
}
|
|
||||||
// ...and the now-empty index key must not be left orphaned.
|
|
||||||
n, err := c.client.Exists(context.Background(), c.buildFieldsKey("root4")).Result()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("exists: %v", err)
|
|
||||||
}
|
|
||||||
if n != 0 {
|
|
||||||
t.Error("orphaned field index left behind after de-indexing")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// The same probe must still work for a record that predates the index.
|
|
||||||
func TestFieldIndex_IndexRemoveIfEmptyHandlesUnindexedRecord(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := c.client.Set(ctx, c.buildKeyPrefix("legacy2")+"title", "x", 0).Err(); err != nil {
|
|
||||||
t.Fatalf("seed: %v", err)
|
|
||||||
}
|
|
||||||
if err := c.IndexAdd("legacy2"); err != nil {
|
|
||||||
t.Fatalf("IndexAdd: %v", err)
|
|
||||||
}
|
|
||||||
removed, err := c.IndexRemoveIfEmpty("legacy2")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("IndexRemoveIfEmpty: %v", err)
|
|
||||||
}
|
|
||||||
if removed {
|
|
||||||
t.Error("de-indexed an un-indexed record that still has a field")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// The reserved name is bookkeeping. It must never reach an API caller as a
|
|
||||||
// metadata field, and an inbound payload must never be able to overwrite the
|
|
||||||
// SET with a string — that would break every subsequent read of the record.
|
|
||||||
func TestFieldIndex_ReservedNameIsNeverMetadata(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
err := c.SetMetadataFlat("root5", map[string]string{
|
|
||||||
"title": "x",
|
|
||||||
FieldsField: "malicious",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SetMetadataFlat: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := mustGetMetadata(t, c, "root5")
|
|
||||||
if _, present := got[FieldsField]; present {
|
|
||||||
t.Errorf("%s surfaced as a metadata field: %v", FieldsField, got)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, map[string]string{"title": "x"}) {
|
|
||||||
t.Errorf("read = %v, want just title", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The index must still be a SET — i.e. not clobbered by the payload.
|
|
||||||
if got := fieldNames(t, c, "root5"); !reflect.DeepEqual(got, []string{"title"}) {
|
|
||||||
t.Errorf("index = %v, want [title]", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.SetProperty("root5", FieldsField, "malicious"); err == nil {
|
|
||||||
t.Error("SetProperty accepted the reserved field name")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deleting a record must take its index with it, or a recreated root would
|
|
||||||
// inherit phantom field names.
|
|
||||||
func TestFieldIndex_DeleteMetadataRemovesTheIndex(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
if err := c.SetMetadataFlat("root6", map[string]string{"title": "x", "year": "2002"}); err != nil {
|
|
||||||
t.Fatalf("SetMetadataFlat: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := c.DeleteMetadata("root6"); err != nil {
|
|
||||||
t.Fatalf("DeleteMetadata: %v", err)
|
|
||||||
}
|
|
||||||
if got := fieldNames(t, c, "root6"); len(got) != 0 {
|
|
||||||
t.Errorf("index survived DeleteMetadata: %v", got)
|
|
||||||
}
|
|
||||||
got, err := c.GetMetadataFlat("root6")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetMetadataFlat: %v", err)
|
|
||||||
}
|
|
||||||
if got != nil {
|
|
||||||
t.Errorf("deleted record still reads back: %v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stale index entry (name present, key gone) is the benign direction: the
|
|
||||||
// MGET reads nil and the field is skipped rather than erroring.
|
|
||||||
func TestFieldIndex_StaleEntryIsHarmless(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := c.SetMetadataFlat("root7", map[string]string{"title": "x"}); err != nil {
|
|
||||||
t.Fatalf("SetMetadataFlat: %v", err)
|
|
||||||
}
|
|
||||||
// Name a field that has no key behind it.
|
|
||||||
if err := c.client.SAdd(ctx, c.buildFieldsKey("root7"), "ghost").Err(); err != nil {
|
|
||||||
t.Fatalf("sadd ghost: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := mustGetMetadata(t, c, "root7")
|
|
||||||
if !reflect.DeepEqual(got, map[string]string{"title": "x"}) {
|
|
||||||
t.Errorf("read = %v, want just title", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// WarmFieldIndexes is what keeps an upgrade from spending hours reading
|
|
||||||
// un-indexed records one full-keyspace scan at a time. It must index every
|
|
||||||
// record in one pass, be idempotent, and leave already-indexed records alone.
|
|
||||||
func TestFieldIndex_WarmBuildsEveryMissingIndex(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Two legacy records seeded straight into Redis (no index), plus one
|
|
||||||
// written through the normal path (already indexed).
|
|
||||||
legacy := map[string]map[string]string{
|
|
||||||
"old1": {"filePath": "/a.mkv", "cids/bafkreiaaa": "true"},
|
|
||||||
"old2": {"filePath": "/b.mkv", "titles/jpn/ナルト": "true", "deep/a/b": "x"},
|
|
||||||
}
|
|
||||||
for root, fields := range legacy {
|
|
||||||
for f, v := range fields {
|
|
||||||
if err := c.client.Set(ctx, c.buildKeyPrefix(root)+f, v, 0).Err(); err != nil {
|
|
||||||
t.Fatalf("seed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := c.IndexAdd(root); err != nil {
|
|
||||||
t.Fatalf("IndexAdd: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := c.SetMetadataFlat("fresh", map[string]string{"title": "x"}); err != nil {
|
|
||||||
t.Fatalf("SetMetadataFlat: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := c.WarmFieldIndexes()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("WarmFieldIndexes: %v", err)
|
|
||||||
}
|
|
||||||
if n < 2 {
|
|
||||||
t.Errorf("warmed %d records, want at least the 2 legacy ones", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := fieldNames(t, c, "old1"); !reflect.DeepEqual(got, []string{"cids/bafkreiaaa", "filePath"}) {
|
|
||||||
t.Errorf("old1 index = %v", got)
|
|
||||||
}
|
|
||||||
if got := fieldNames(t, c, "old2"); !reflect.DeepEqual(got, []string{"deep/a/b", "filePath", "titles/jpn/ナルト"}) {
|
|
||||||
t.Errorf("old2 index = %v", got)
|
|
||||||
}
|
|
||||||
if got := fieldNames(t, c, "fresh"); !reflect.DeepEqual(got, []string{"title"}) {
|
|
||||||
t.Errorf("fresh index disturbed = %v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads must now agree with what the records actually hold.
|
|
||||||
for root, want := range legacy {
|
|
||||||
if got := mustGetMetadata(t, c, root); !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("%s read = %v, want %v", root, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idempotent: a second pass changes nothing.
|
|
||||||
if _, err := c.WarmFieldIndexes(); err != nil {
|
|
||||||
t.Fatalf("second WarmFieldIndexes: %v", err)
|
|
||||||
}
|
|
||||||
if got := fieldNames(t, c, "old2"); !reflect.DeepEqual(got, []string{"deep/a/b", "filePath", "titles/jpn/ナルト"}) {
|
|
||||||
t.Errorf("old2 index after second warm = %v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// The bulk search read enumerates the whole file: keyspace and MGETs what it
|
|
||||||
// finds. The index keys are SETs living in that same namespace, so it has to
|
|
||||||
// skip them or the MGET fails with WRONGTYPE and search dies wholesale.
|
|
||||||
func TestFieldIndex_SearchIndexTuplesSkipTheIndexKeys(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
if err := c.SetMetadataFlat("root8", map[string]string{
|
|
||||||
"title": "Naruto Shippuden",
|
|
||||||
"fileName": "naruto.mkv",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("SetMetadataFlat: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tuples, err := c.GetSearchIndexTuples()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSearchIndexTuples: %v", err)
|
|
||||||
}
|
|
||||||
var found bool
|
|
||||||
for _, tu := range tuples {
|
|
||||||
if tu.HashID != "root8" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
found = true
|
|
||||||
if _, present := tu.Fields[FieldsField]; present {
|
|
||||||
t.Errorf("%s leaked into the search tuple: %v", FieldsField, tu.Fields)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatal("record missing from search tuples")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/claim"
|
||||||
|
"github.com/metazla/meta-core/internal/identity"
|
||||||
|
"github.com/metazla/meta-core/internal/kv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openClient opens (and starts) a client on dir's truth + cache files.
|
||||||
|
func openClient(t *testing.T, dir string) *Client {
|
||||||
|
t.Helper()
|
||||||
|
c := NewClient()
|
||||||
|
if err := c.Connect(filepath.Join(dir, "meta-core.db"), filepath.Join(dir, "cache.db")); err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.EnsureSchemaVersion(SchemaVersion); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Start(); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestClient opens a fresh store in the test's temp dir.
|
||||||
|
func newTestClient(t *testing.T) *Client {
|
||||||
|
t.Helper()
|
||||||
|
c := openClient(t, t.TempDir())
|
||||||
|
t.Cleanup(func() { _ = c.Close() })
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// signer returns a deterministic test identity (scalar n repeated).
|
||||||
|
func signer(t *testing.T, n byte) *identity.Identity {
|
||||||
|
t.Helper()
|
||||||
|
id, err := identity.Import(fmt.Sprintf("%02x%062x", n, 1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("identity: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// vote builds a signed claim; v is a string (text), []byte (ciphertext) or nil
|
||||||
|
// (retraction).
|
||||||
|
func vote(t *testing.T, id *identity.Identity, cid, key string, v any, ts uint64) claim.Claim {
|
||||||
|
t.Helper()
|
||||||
|
cl := claim.Claim{CID: cid, UID: id.UID, Key: key, TS: ts}
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
cl.Value = claim.Text(x)
|
||||||
|
case []byte:
|
||||||
|
cl.Value = claim.Bytes(x)
|
||||||
|
case nil:
|
||||||
|
default:
|
||||||
|
t.Fatalf("vote value %T", v)
|
||||||
|
}
|
||||||
|
if err := cl.Sign(id); err != nil {
|
||||||
|
t.Fatalf("sign: %v", err)
|
||||||
|
}
|
||||||
|
return cl
|
||||||
|
}
|
||||||
|
|
||||||
|
// write posts claims and returns their statuses.
|
||||||
|
func write(t *testing.T, c *Client, claims ...claim.Claim) []ClaimStatus {
|
||||||
|
t.Helper()
|
||||||
|
res, err := c.WriteClaims(context.Background(), claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
out := make([]ClaimStatus, len(res))
|
||||||
|
for i, r := range res {
|
||||||
|
out[i] = r.Status
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustAccept posts claims and fails unless every one is accepted.
|
||||||
|
func mustAccept(t *testing.T, c *Client, claims ...claim.Claim) {
|
||||||
|
t.Helper()
|
||||||
|
for i, st := range write(t, c, claims...) {
|
||||||
|
if st != StatusAccepted {
|
||||||
|
t.Fatalf("claim %d (%s/%s): %s", i, claims[i].CID, claims[i].Key, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed writes merged rows straight into the cache — the derived layout search
|
||||||
|
// and the counters work on — bypassing claims. Search tests pin SQL behaviour
|
||||||
|
// over that layout; how rows get there is the apply loop's business.
|
||||||
|
func seed(t *testing.T, c *Client, records map[string]map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
for id, fields := range records {
|
||||||
|
setMerged(t, c, id, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMerged(t *testing.T, c *Client, rid string, fields map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := c.update(func(tx *kv.Tx) error {
|
||||||
|
for k, v := range fields {
|
||||||
|
if err := tx.Put(recordPrefix(rid)+k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Put(rootKey(rid), ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return refreshSearchTx(tx, rid)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed %s: %v", rid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteMerged(t *testing.T, c *Client, rid string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := c.update(func(tx *kv.Tx) error {
|
||||||
|
if _, err := tx.DeletePrefix(recordPrefix(rid), 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Delete(rootKey(rid)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return deleteSearchRowTx(tx, rid)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("delete %s: %v", rid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allKeys lists every cache kv row (full scan).
|
||||||
|
func allKeys(t *testing.T, c *Client) []string {
|
||||||
|
t.Helper()
|
||||||
|
var keys []string
|
||||||
|
if err := c.Store().ScanPrefix(context.Background(), "", 1000, func(rows []kv.KV) error {
|
||||||
|
for _, r := range rows {
|
||||||
|
keys = append(keys, r.K)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// rid resolves a cid to its record (fails when unknown).
|
||||||
|
func rid(t *testing.T, c *Client, cid string) string {
|
||||||
|
t.Helper()
|
||||||
|
r, err := c.GetByCID(cid)
|
||||||
|
if err != nil || r == "" {
|
||||||
|
t.Fatalf("cid %s unresolved (%v)", cid, err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(t *testing.T, c *Client, cid string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
f, err := c.GetMetadataFlat(rid(t, c, cid))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
|
|
||||||
"github.com/metazla/meta-core/internal/cid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The one-shot corpus sweep for meta-read `docs/reading-model.md` §7:
|
|
||||||
// identity and position keys are resolved, never copied from a query, and a
|
|
||||||
// card carries no position keys and no `workcid`.
|
|
||||||
//
|
|
||||||
// # Why a sweep
|
|
||||||
//
|
|
||||||
// A feeder answering a `workcid:` / `anilistid:` query could stamp the query's
|
|
||||||
// own filter values onto what it returned. The gateway persists whatever passes
|
|
||||||
// the filter, so the copy became a permanent binding — and stored records never
|
|
||||||
// pick up the writer fix: a covered `(upstream, query)` never reaches the
|
|
||||||
// feeder again, and the streaming dedup keeps meta-core's copy over a fresh
|
|
||||||
// one. So the copies already at rest are removed here, once.
|
|
||||||
//
|
|
||||||
// What the copies cost: an `anilistid` on a MangaDex site card claims an
|
|
||||||
// AniList binding nobody resolved, and meta-read reads a bare `anilistid` as an
|
|
||||||
// anchor — the site card then competes with (and can hide) the real AniList
|
|
||||||
// card for the same work. A `workcid` or `chapterNumber` on a card makes a work
|
|
||||||
// join and sort like a position inside one.
|
|
||||||
//
|
|
||||||
// # What is swept, and what deliberately is not
|
|
||||||
//
|
|
||||||
// - Only `domain=literature` + `fileType=card` records. Nothing else is read
|
|
||||||
// for writing, whatever keys it carries.
|
|
||||||
// - `workcid`, `chapterNumber`, `volumeNumber`, `chapterStart`, `chapterEnd`
|
|
||||||
// go from **every** such card: a card is a work, never a position in one,
|
|
||||||
// so no legitimate writer puts them there.
|
|
||||||
// - `anilistid` goes only from a **site card** — a card whose own `cids/`
|
|
||||||
// key-set holds a site-form card locator and no locator of any other form.
|
|
||||||
// On an anchor card the id *is* the card's identity (its locator is
|
|
||||||
// `("anilist", …)`). A card holding both forms (a cross-source merge) keeps
|
|
||||||
// it: which half the id belongs to is not recoverable at rest, and leaving
|
|
||||||
// a stamp is the safe direction.
|
|
||||||
// - **Chapters are never swept.** A bare `anilistid` on a chapter is graded
|
|
||||||
// Matched by rule, and a query-copied stamp is indistinguishable at rest
|
|
||||||
// from a title-matched one — deleting would drop real bindings with the
|
|
||||||
// fake ones.
|
|
||||||
//
|
|
||||||
// # Contract
|
|
||||||
//
|
|
||||||
// Dry-run unless `apply`: the sweep deletes keys, so the default call only
|
|
||||||
// reports. Idempotent: a swept record plans nothing on the next pass, so the
|
|
||||||
// endpoint is safe to call repeatedly and on a fresh box.
|
|
||||||
|
|
||||||
// literatureEchoAnchorSources are the card-locator sources that name an id-bag
|
|
||||||
// work (an anchor), never a site.
|
|
||||||
//
|
|
||||||
// ⚠ Mirror of meta-read `card_cid::is_host_form`. meta-read spells out only
|
|
||||||
// `anilist` and `openlibrary`; the others carry no '.', so the '.' test already
|
|
||||||
// excludes them and the two rules agree on every locator minted today. They are
|
|
||||||
// listed so the rule reads on its own here.
|
|
||||||
var literatureEchoAnchorSources = map[string]bool{
|
|
||||||
"anilist": true,
|
|
||||||
"openlibrary": true,
|
|
||||||
"tmdb": true,
|
|
||||||
"imdb": true,
|
|
||||||
"musicbrainz": true,
|
|
||||||
"mal": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// literatureCardEchoFields are removed from every literature card carrying
|
|
||||||
// them — position keys and the work reference, none of which a card may hold.
|
|
||||||
var literatureCardEchoFields = []string{
|
|
||||||
"workcid",
|
|
||||||
"chapterNumber",
|
|
||||||
"volumeNumber",
|
|
||||||
"chapterStart",
|
|
||||||
"chapterEnd",
|
|
||||||
}
|
|
||||||
|
|
||||||
// literatureEchoReadFields is the per-record MGET layout: the two gate fields,
|
|
||||||
// then `anilistid`, then the echo fields in literatureCardEchoFields order.
|
|
||||||
var literatureEchoReadFields = append(
|
|
||||||
[]string{"domain", "fileType", "anilistid"},
|
|
||||||
literatureCardEchoFields...,
|
|
||||||
)
|
|
||||||
|
|
||||||
// LiteratureEchoSweep is the result of one sweep pass. Planned and Fixed count
|
|
||||||
// records; ByKey counts fields per key — removed on an apply pass, that would
|
|
||||||
// be removed on a dry run. ByKey is never nil so it serialises as `{}`.
|
|
||||||
type LiteratureEchoSweep struct {
|
|
||||||
Planned int
|
|
||||||
Fixed int
|
|
||||||
ByKey map[string]int
|
|
||||||
}
|
|
||||||
|
|
||||||
// literatureEchoPlan is one record's pending deletes, in a fixed order
|
|
||||||
// (`anilistid` first, then literatureCardEchoFields order).
|
|
||||||
type literatureEchoPlan struct {
|
|
||||||
hashID string
|
|
||||||
fields []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// isSiteFormCardSource is the site-form rule on an already-decoded source: not
|
|
||||||
// an anchor source, and shaped like a host.
|
|
||||||
func isSiteFormCardSource(source string) bool {
|
|
||||||
return !literatureEchoAnchorSources[source] && strings.Contains(source, ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
// isSiteFormCardLocator reports whether a bare CID is a card locator (0x1007)
|
|
||||||
// whose source is a site (`mangadex.org`), not an anchor (`anilist`).
|
|
||||||
func isSiteFormCardLocator(cidStr string) bool {
|
|
||||||
source, _, err := cid.DecodeCard(cidStr)
|
|
||||||
return err == nil && isSiteFormCardSource(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isSiteCard reports whether a card's field names prove it a site card: at
|
|
||||||
// least one site-form `cids/` card locator and no card locator of another form.
|
|
||||||
// Non-card members (a content CID) say nothing about which work the card is and
|
|
||||||
// are ignored. An empty field list — a record with no field-name index — is
|
|
||||||
// never a site card, which keeps its `anilistid`; see planLiteratureEchoes.
|
|
||||||
func isSiteCard(fields []string) bool {
|
|
||||||
site := false
|
|
||||||
for _, f := range fields {
|
|
||||||
member := cidFromKeysetField(f)
|
|
||||||
if member == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
source, _, err := cid.DecodeCard(member)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !isSiteFormCardSource(source) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
site = true
|
|
||||||
}
|
|
||||||
return site
|
|
||||||
}
|
|
||||||
|
|
||||||
// SweepLiteratureEchoes removes query echoes stored on literature cards (see
|
|
||||||
// the file comment). With apply=false it only plans.
|
|
||||||
//
|
|
||||||
// Reads in batched passes (never a per-record SCAN — a single record read
|
|
||||||
// costs a full keyspace sweep on this store), then deletes only the planned
|
|
||||||
// fields, through DeleteProperty so the field-name index and the search-dirty
|
|
||||||
// set stay consistent.
|
|
||||||
func (c *Client) SweepLiteratureEchoes(ctx context.Context, apply bool) (LiteratureEchoSweep, error) {
|
|
||||||
res := LiteratureEchoSweep{ByKey: map[string]int{}}
|
|
||||||
|
|
||||||
plans, err := c.planLiteratureEchoes(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
res.Planned = len(plans)
|
|
||||||
|
|
||||||
if !apply {
|
|
||||||
for _, p := range plans {
|
|
||||||
for _, f := range p.fields {
|
|
||||||
res.ByKey[f]++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Writes take the client lock themselves, so they happen after the read
|
|
||||||
// phase has released it. ByKey counts what was actually deleted, so a
|
|
||||||
// failure midway reports the true partial state.
|
|
||||||
for _, p := range plans {
|
|
||||||
// DeleteProperty carries its own per-call timeout; checking ctx here is
|
|
||||||
// what makes the wrapper's deadline bound the whole write phase.
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
for _, f := range p.fields {
|
|
||||||
if err := c.DeleteProperty(p.hashID, f); err != nil {
|
|
||||||
return res, fmt.Errorf("delete %s on %s: %w", f, p.hashID, err)
|
|
||||||
}
|
|
||||||
res.ByKey[f]++
|
|
||||||
}
|
|
||||||
res.Fixed++
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// planLiteratureEchoes is the read half: what would be deleted, without
|
|
||||||
// deleting it.
|
|
||||||
func (c *Client) planLiteratureEchoes(ctx context.Context) ([]literatureEchoPlan, error) {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return nil, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
hashIDs, err := c.client.SMembers(ctx, c.buildIndexKey()).Result()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("smembers index: %w", err)
|
|
||||||
}
|
|
||||||
if len(hashIDs) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 1: the gate and echo fields for every record, in a fixed order so
|
|
||||||
// the MGET response decodes by position: keys[i*per+n] belongs to
|
|
||||||
// hashIDs[i].
|
|
||||||
per := len(literatureEchoReadFields)
|
|
||||||
keys := make([]string, 0, len(hashIDs)*per)
|
|
||||||
for _, h := range hashIDs {
|
|
||||||
prefix := c.buildKeyPrefix(h)
|
|
||||||
for _, f := range literatureEchoReadFields {
|
|
||||||
keys = append(keys, prefix+f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chunked so one MGET never carries the whole corpus.
|
|
||||||
const chunk = 3000
|
|
||||||
values := make([]interface{}, 0, len(keys))
|
|
||||||
for start := 0; start < len(keys); start += chunk {
|
|
||||||
end := start + chunk
|
|
||||||
if end > len(keys) {
|
|
||||||
end = len(keys)
|
|
||||||
}
|
|
||||||
got, err := c.client.MGet(ctx, keys[start:end]...).Result()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("mget: %w", err)
|
|
||||||
}
|
|
||||||
values = append(values, got...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A nil slot is an absent key. A present key holding "" is still an echo
|
|
||||||
// and still goes, so presence — not the value — decides.
|
|
||||||
get := func(i int) (string, bool) {
|
|
||||||
if i >= len(values) || values[i] == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
s, ok := values[i].(string)
|
|
||||||
return s, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
type candidate struct {
|
|
||||||
hashID string
|
|
||||||
hasAnilist bool
|
|
||||||
echoes []string
|
|
||||||
}
|
|
||||||
var cands []candidate
|
|
||||||
var probe []string // candidates whose anilistid needs their cids/ key-set
|
|
||||||
for i, h := range hashIDs {
|
|
||||||
base := i * per
|
|
||||||
domain, _ := get(base)
|
|
||||||
fileType, _ := get(base + 1)
|
|
||||||
if domain != "literature" || fileType != "card" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cand := candidate{hashID: h}
|
|
||||||
_, cand.hasAnilist = get(base + 2)
|
|
||||||
for j, f := range literatureCardEchoFields {
|
|
||||||
if _, ok := get(base + 3 + j); ok {
|
|
||||||
cand.echoes = append(cand.echoes, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !cand.hasAnilist && len(cand.echoes) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if cand.hasAnilist {
|
|
||||||
probe = append(probe, h)
|
|
||||||
}
|
|
||||||
cands = append(cands, cand)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 2: the field-name index of only the cards carrying an `anilistid`
|
|
||||||
// — the `cids/` members are field NAMES, so the index is where they are
|
|
||||||
// listed. Pipelined per chunk; still no SCAN.
|
|
||||||
//
|
|
||||||
// A card with no index (never touched since before the index existed, and
|
|
||||||
// missed by the startup WarmFieldIndexes pass) reads as an empty set and
|
|
||||||
// keeps its `anilistid`: proving it a site card would need exactly the
|
|
||||||
// per-record SCAN this sweep refuses, and a stamp left in place is the
|
|
||||||
// safe direction.
|
|
||||||
siteCard := make(map[string]bool, len(probe))
|
|
||||||
for start := 0; start < len(probe); start += mgetBatch {
|
|
||||||
end := start + mgetBatch
|
|
||||||
if end > len(probe) {
|
|
||||||
end = len(probe)
|
|
||||||
}
|
|
||||||
pipe := c.client.Pipeline()
|
|
||||||
cmds := make([]*redis.StringSliceCmd, 0, end-start)
|
|
||||||
for _, h := range probe[start:end] {
|
|
||||||
cmds = append(cmds, pipe.SMembers(ctx, c.buildFieldsKey(h)))
|
|
||||||
}
|
|
||||||
if _, err := pipe.Exec(ctx); err != nil && err != redis.Nil {
|
|
||||||
return nil, fmt.Errorf("smembers fields: %w", err)
|
|
||||||
}
|
|
||||||
for k, cmd := range cmds {
|
|
||||||
fields, err := cmd.Result()
|
|
||||||
if err != nil && err != redis.Nil {
|
|
||||||
return nil, fmt.Errorf("smembers fields %s: %w", probe[start+k], err)
|
|
||||||
}
|
|
||||||
siteCard[probe[start+k]] = isSiteCard(fields)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
plans := make([]literatureEchoPlan, 0, len(cands))
|
|
||||||
for _, cand := range cands {
|
|
||||||
var fields []string
|
|
||||||
if cand.hasAnilist && siteCard[cand.hashID] {
|
|
||||||
fields = append(fields, "anilistid")
|
|
||||||
}
|
|
||||||
fields = append(fields, cand.echoes...)
|
|
||||||
if len(fields) > 0 {
|
|
||||||
plans = append(plans, literatureEchoPlan{hashID: cand.hashID, fields: fields})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return plans, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SweepLiteratureEchoesWithTimeout is the convenience wrapper used by the API
|
|
||||||
// handler. Generous timeout: the read phase is batched, but the write phase is
|
|
||||||
// one round-trip per deleted field.
|
|
||||||
func (c *Client) SweepLiteratureEchoesWithTimeout(apply bool) (LiteratureEchoSweep, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
return c.SweepLiteratureEchoes(ctx, apply)
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base32"
|
|
||||||
"encoding/binary"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pinned from meta-read `src/card_cid.rs` — literals, not round-trips.
|
|
||||||
const (
|
|
||||||
literatureAnchorCardCID = "bagdsaaaua5qw42lmnfzxi3lbnztwcorrga2tonzy" // ("anilist", "manga:105778")
|
|
||||||
literatureSiteCardCID = "bagdsaabybrwwc3thmfsgk6bon5zgol3nmfxgoyjpmiygenzsgftgmlldgm4dqljugq4dmllbmeygmlldgjrdaytcgmzdcnjrgi"
|
|
||||||
// A non-card CID a card may also carry.
|
|
||||||
literatureContentCID = "bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mintCardCID encodes a card locator the way meta-feeder-sdk
|
|
||||||
// `compute_card_cid` does. Asserted against the pinned literals before use.
|
|
||||||
func mintCardCID(source, id string) string {
|
|
||||||
digest := binary.AppendUvarint(nil, uint64(len(source)))
|
|
||||||
digest = append(digest, source...)
|
|
||||||
digest = append(digest, id...)
|
|
||||||
wire := []byte{0x01}
|
|
||||||
wire = binary.AppendUvarint(wire, 0x1007)
|
|
||||||
wire = append(wire, 0x00)
|
|
||||||
wire = binary.AppendUvarint(wire, uint64(len(digest)))
|
|
||||||
wire = append(wire, digest...)
|
|
||||||
return "b" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(wire))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsSiteFormCardLocator(t *testing.T) {
|
|
||||||
if got := mintCardCID("anilist", "manga:105778"); got != literatureAnchorCardCID {
|
|
||||||
t.Fatalf("test encoder drifted: %s != %s", got, literatureAnchorCardCID)
|
|
||||||
}
|
|
||||||
if got := mintCardCID("mangadex.org", "/manga/b0b721ff-c388-4486-aa0f-c2b0bb321512"); got != literatureSiteCardCID {
|
|
||||||
t.Fatalf("test encoder drifted: %s != %s", got, literatureSiteCardCID)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
cid string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"anilist anchor (golden)", literatureAnchorCardCID, false},
|
|
||||||
{"mangadex site (golden)", literatureSiteCardCID, true},
|
|
||||||
{"openlibrary anchor", mintCardCID("openlibrary", "OL45804W"), false},
|
|
||||||
{"another site", mintCardCID("weebcentral.com", "/series/01J76XY/chainsaw-man"), true},
|
|
||||||
// Not an anchor source, but not host-shaped either: meta-read reads it
|
|
||||||
// as an id-bag key, so it must not be treated as a site.
|
|
||||||
{"dotless unknown source", mintCardCID("suwayomi", "42"), false},
|
|
||||||
{"content CID", literatureContentCID, false},
|
|
||||||
{"title key", "title:chainsaw man", false},
|
|
||||||
} {
|
|
||||||
if got := isSiteFormCardLocator(tc.cid); got != tc.want {
|
|
||||||
t.Errorf("%s: isSiteFormCardLocator = %v, want %v", tc.name, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedLiteratureCorpus writes one record per rule the sweep has to honour.
|
|
||||||
func seedLiteratureCorpus(t *testing.T, c *Client) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// A site card carrying every echo: all of it goes.
|
|
||||||
seedRecord(t, c, "site-card", map[string]string{
|
|
||||||
"domain": "literature",
|
|
||||||
"fileType": "card",
|
|
||||||
"title": "Chainsaw Man",
|
|
||||||
"cids/" + literatureSiteCardCID: "true",
|
|
||||||
"anilistid": "105778",
|
|
||||||
"workcid": literatureSiteCardCID,
|
|
||||||
"chapterNumber": "12",
|
|
||||||
"volumeNumber": "2",
|
|
||||||
"chapterStart": "1",
|
|
||||||
"chapterEnd": "20",
|
|
||||||
})
|
|
||||||
// An anchor card: its anilistid is its identity and stays; the position
|
|
||||||
// echo and the workcid still go.
|
|
||||||
seedRecord(t, c, "anchor-card", map[string]string{
|
|
||||||
"domain": "literature",
|
|
||||||
"fileType": "card",
|
|
||||||
"title": "Chainsaw Man",
|
|
||||||
"cids/" + literatureAnchorCardCID: "true",
|
|
||||||
"anilistid": "105778",
|
|
||||||
"workcid": literatureAnchorCardCID,
|
|
||||||
"chapterNumber": "3",
|
|
||||||
})
|
|
||||||
// A site card that is already clean: nothing planned.
|
|
||||||
seedRecord(t, c, "clean-site-card", map[string]string{
|
|
||||||
"domain": "literature",
|
|
||||||
"fileType": "card",
|
|
||||||
"cids/" + literatureSiteCardCID: "true",
|
|
||||||
})
|
|
||||||
// Both locator forms: which one the id belongs to is unknowable, so it
|
|
||||||
// stays.
|
|
||||||
seedRecord(t, c, "merged-card", map[string]string{
|
|
||||||
"domain": "literature",
|
|
||||||
"fileType": "card",
|
|
||||||
"cids/" + literatureSiteCardCID: "true",
|
|
||||||
"cids/" + literatureAnchorCardCID: "true",
|
|
||||||
"anilistid": "105778",
|
|
||||||
})
|
|
||||||
// No card locator at all: not provably a site card.
|
|
||||||
seedRecord(t, c, "unlocated-card", map[string]string{
|
|
||||||
"domain": "literature",
|
|
||||||
"fileType": "card",
|
|
||||||
"cids/" + literatureContentCID: "true",
|
|
||||||
"anilistid": "105778",
|
|
||||||
})
|
|
||||||
// A stored empty value is still an echo.
|
|
||||||
seedRecord(t, c, "blank-echo-card", map[string]string{
|
|
||||||
"domain": "literature",
|
|
||||||
"fileType": "card",
|
|
||||||
"volumeNumber": "",
|
|
||||||
})
|
|
||||||
// A chapter is never swept, whatever it carries.
|
|
||||||
seedRecord(t, c, "chapter", map[string]string{
|
|
||||||
"domain": "literature",
|
|
||||||
"fileType": "archive",
|
|
||||||
"cids/" + literatureContentCID: "true",
|
|
||||||
"anilistid": "105778",
|
|
||||||
"workcid": literatureSiteCardCID,
|
|
||||||
"chapterNumber": "12",
|
|
||||||
})
|
|
||||||
// Not literature: out of scope even though it is a card.
|
|
||||||
seedRecord(t, c, "screen-card", map[string]string{
|
|
||||||
"domain": "screen",
|
|
||||||
"fileType": "card",
|
|
||||||
"workcid": literatureSiteCardCID,
|
|
||||||
"chapterNumber": "1",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanLiteratureEchoes_ScopesToLiteratureCards(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
seedLiteratureCorpus(t, c)
|
|
||||||
|
|
||||||
plans, err := c.planLiteratureEchoes(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("plan: %v", err)
|
|
||||||
}
|
|
||||||
got := map[string][]string{}
|
|
||||||
for _, p := range plans {
|
|
||||||
got[p.hashID] = p.fields
|
|
||||||
}
|
|
||||||
want := map[string][]string{
|
|
||||||
"site-card": {"anilistid", "workcid", "chapterNumber", "volumeNumber", "chapterStart", "chapterEnd"},
|
|
||||||
"anchor-card": {"workcid", "chapterNumber"},
|
|
||||||
"blank-echo-card": {"volumeNumber"},
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("plan =\n %v\nwant\n %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSweepLiteratureEchoes_DryRunThenApply(t *testing.T) {
|
|
||||||
c, s := newTestClient(t, "mm:")
|
|
||||||
seedLiteratureCorpus(t, c)
|
|
||||||
|
|
||||||
wantByKey := map[string]int{
|
|
||||||
"anilistid": 1,
|
|
||||||
"workcid": 2,
|
|
||||||
"chapterNumber": 2,
|
|
||||||
"volumeNumber": 2,
|
|
||||||
"chapterStart": 1,
|
|
||||||
"chapterEnd": 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dry run: counts, and nothing written.
|
|
||||||
dry, err := c.SweepLiteratureEchoes(context.Background(), false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("dry run: %v", err)
|
|
||||||
}
|
|
||||||
if dry.Planned != 3 || dry.Fixed != 0 {
|
|
||||||
t.Errorf("dry run planned/fixed = %d/%d, want 3/0", dry.Planned, dry.Fixed)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(dry.ByKey, wantByKey) {
|
|
||||||
t.Errorf("dry run byKey = %v, want %v", dry.ByKey, wantByKey)
|
|
||||||
}
|
|
||||||
if m, _ := c.GetMetadataFlat("site-card"); m["anilistid"] != "105778" {
|
|
||||||
t.Fatalf("dry run deleted anilistid: %v", m)
|
|
||||||
}
|
|
||||||
|
|
||||||
applied, err := c.SweepLiteratureEchoes(context.Background(), true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("apply: %v", err)
|
|
||||||
}
|
|
||||||
if applied.Planned != 3 || applied.Fixed != 3 {
|
|
||||||
t.Errorf("apply planned/fixed = %d/%d, want 3/3", applied.Planned, applied.Fixed)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(applied.ByKey, wantByKey) {
|
|
||||||
t.Errorf("apply byKey = %v, want %v", applied.ByKey, wantByKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
site, err := c.GetMetadataFlat("site-card")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read site-card: %v", err)
|
|
||||||
}
|
|
||||||
for _, k := range []string{"anilistid", "workcid", "chapterNumber", "volumeNumber", "chapterStart", "chapterEnd"} {
|
|
||||||
if _, ok := site[k]; ok {
|
|
||||||
t.Errorf("site-card still carries %s", k)
|
|
||||||
}
|
|
||||||
// The flat key and its field-name index entry must both be gone, or the
|
|
||||||
// next read would MGET a ghost field.
|
|
||||||
if s.Exists("mm:file:site-card/" + k) {
|
|
||||||
t.Errorf("site-card flat key %s survived", k)
|
|
||||||
}
|
|
||||||
if member, _ := s.IsMember("mm:file:site-card/"+FieldsField, k); member {
|
|
||||||
t.Errorf("site-card field index still lists %s", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if site["title"] != "Chainsaw Man" || site["cids/"+literatureSiteCardCID] != "true" {
|
|
||||||
t.Errorf("site-card lost fields it should keep: %v", site)
|
|
||||||
}
|
|
||||||
|
|
||||||
anchor, _ := c.GetMetadataFlat("anchor-card")
|
|
||||||
if anchor["anilistid"] != "105778" {
|
|
||||||
t.Errorf("anchor-card anilistid = %q, want kept", anchor["anilistid"])
|
|
||||||
}
|
|
||||||
if _, ok := anchor["workcid"]; ok {
|
|
||||||
t.Errorf("anchor-card still carries workcid")
|
|
||||||
}
|
|
||||||
|
|
||||||
chapter, _ := c.GetMetadataFlat("chapter")
|
|
||||||
if chapter["anilistid"] != "105778" || chapter["workcid"] != literatureSiteCardCID || chapter["chapterNumber"] != "12" {
|
|
||||||
t.Errorf("chapter was touched: %v", chapter)
|
|
||||||
}
|
|
||||||
screen, _ := c.GetMetadataFlat("screen-card")
|
|
||||||
if screen["workcid"] != literatureSiteCardCID {
|
|
||||||
t.Errorf("screen-card was touched: %v", screen)
|
|
||||||
}
|
|
||||||
merged, _ := c.GetMetadataFlat("merged-card")
|
|
||||||
if merged["anilistid"] != "105778" {
|
|
||||||
t.Errorf("merged-card anilistid = %q, want kept", merged["anilistid"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idempotent: the endpoint is called by hand, and a second call must find
|
|
||||||
// nothing — with byKey still an empty map, not nil.
|
|
||||||
again, err := c.SweepLiteratureEchoes(context.Background(), true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second pass: %v", err)
|
|
||||||
}
|
|
||||||
if again.Planned != 0 || again.Fixed != 0 || again.ByKey == nil || len(again.ByKey) != 0 {
|
|
||||||
t.Errorf("second pass = %+v, want nothing planned and an empty byKey", again)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
// Box-local file facts: where the watcher saw each file on this disk.
|
||||||
|
//
|
||||||
|
// local_file(path) → midhash, size, mtime (cache table, unsigned)
|
||||||
|
//
|
||||||
|
// These are facts about this disk, not about the content, so they are never
|
||||||
|
// claims and never travel. meta-core cannot sign anyway, and a rescan of
|
||||||
|
// /files rebuilds them. The midhash gets a cid_group entry in truth (local
|
||||||
|
// naming, not a vote), and the record view joins filePath / sizeByte /
|
||||||
|
// mtimeNano in as `file:<rid>/<field>` rows — which publishes the meta events
|
||||||
|
// meta-fuse builds its tree from.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/kv"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cacheTablesSchema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS claim_by_pid(
|
||||||
|
pid INTEGER NOT NULL,
|
||||||
|
cid TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (pid, cid, key)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
CREATE TABLE IF NOT EXISTS local_file(
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
midhash TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
mtime INTEGER NOT NULL
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
CREATE INDEX IF NOT EXISTS local_file_midhash ON local_file(midhash, path);
|
||||||
|
`
|
||||||
|
|
||||||
|
// RegisterFileTuple records one physical file under its midhash256 CID, the
|
||||||
|
// way the watcher does for every file it scans (and POST /api/files/register
|
||||||
|
// for a box with the watcher off). Returns the record's rid and whether its
|
||||||
|
// group was newly created.
|
||||||
|
func (c *Client) RegisterFileTuple(absPath string, size, mtimeNano int64, midhash string) (string, bool, error) {
|
||||||
|
if midhash == "" {
|
||||||
|
return "", false, fmt.Errorf("midhash is required")
|
||||||
|
}
|
||||||
|
truth, err := c.truthStore()
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
var rid string
|
||||||
|
var created bool
|
||||||
|
ctx, cancel := opCtx()
|
||||||
|
defer cancel()
|
||||||
|
if err := truth.Update(ctx, func(tx *kv.Tx) error {
|
||||||
|
var err error
|
||||||
|
rid, created, err = ensureGroup(tx, midhash)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
err = c.update(func(tx *kv.Tx) error {
|
||||||
|
var prev string
|
||||||
|
err := tx.QueryRow(`SELECT midhash FROM local_file WHERE path = ?`, absPath).Scan(&prev)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO local_file(path, midhash, size, mtime) VALUES(?, ?, ?, ?)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET midhash = excluded.midhash, size = excluded.size, mtime = excluded.mtime`,
|
||||||
|
absPath, midhash, size, mtimeNano); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := syncMember(tx, midhash, rid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.remergeLocalTx(tx, rid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if prev != "" && prev != midhash {
|
||||||
|
// The file at this path changed content: its old record loses it.
|
||||||
|
if old, _, err := tx.Get(tx.Context(), CIDIndexPrefix+prev); err == nil && old != "" && old != rid {
|
||||||
|
return c.remergeLocalTx(tx, old)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return rid, created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFileTuple forgets a path the watcher saw disappear. Only the local
|
||||||
|
// facts go — the record's claims stay (facts about content stay true).
|
||||||
|
func (c *Client) RemoveFileTuple(absPath string) error {
|
||||||
|
return c.update(func(tx *kv.Tx) error {
|
||||||
|
var midhash string
|
||||||
|
err := tx.QueryRow(`SELECT midhash FROM local_file WHERE path = ?`, absPath).Scan(&midhash)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DELETE FROM local_file WHERE path = ?`, absPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rid, _, err := tx.Get(tx.Context(), CIDIndexPrefix+midhash)
|
||||||
|
if err != nil || rid == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.remergeLocalTx(tx, rid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// remergeLocalTx recomputes the box-local fields of rid (and its search row
|
||||||
|
// when filePath changed).
|
||||||
|
func (c *Client) remergeLocalTx(tx *kv.Tx, rid string) error {
|
||||||
|
cids, err := cidsForRoot(tx.Context(), tx, rid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
searchChanged := false
|
||||||
|
for _, f := range localFields {
|
||||||
|
changed, err := c.remergeTx(tx, rid, cids, f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
searchChanged = searchChanged || (changed && affectsHaystack(f))
|
||||||
|
}
|
||||||
|
if searchChanged {
|
||||||
|
return refreshSearchTx(tx, rid)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileTuple holds the (rid, path, size, mtime) record for one file. Used by
|
||||||
|
// the watcher hydrator at startup to avoid re-mid-hashing files whose tuple
|
||||||
|
// matches what's already stored.
|
||||||
|
type FileTuple struct {
|
||||||
|
HashID string
|
||||||
|
FilePath string
|
||||||
|
Size int64
|
||||||
|
MtimeNano int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTuplesForAllFiles returns one FileTuple per local file.
|
||||||
|
func (c *Client) GetTuplesForAllFiles() ([]FileTuple, error) {
|
||||||
|
s, err := c.kv()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
rows, err := s.DB().QueryContext(ctx,
|
||||||
|
`SELECT l.path, l.size, l.mtime, COALESCE(k.v, '') FROM local_file l
|
||||||
|
LEFT JOIN kv k ON k.k = 'cid:' || l.midhash ORDER BY l.path`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []FileTuple
|
||||||
|
for rows.Next() {
|
||||||
|
var t FileTuple
|
||||||
|
if err := rows.Scan(&t.FilePath, &t.Size, &t.MtimeNano, &t.HashID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// localPaths lists every local path whose midhash is one of cids.
|
||||||
|
func localPaths(ctx context.Context, q interface {
|
||||||
|
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||||
|
}, cids []string) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for _, cid := range cids {
|
||||||
|
rows, err := q.QueryContext(ctx, `SELECT path FROM local_file WHERE midhash = ? ORDER BY path`, cid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var p string
|
||||||
|
if err := rows.Scan(&p); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/claim"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Vote is one signer's claim on one key, as the merge sees it. Pid identifies
|
||||||
|
// the signer (one vote per signer per key per group); Uid is carried for the
|
||||||
|
// stateless /api/merge, where claims arrive without a local pid.
|
||||||
|
type Vote struct {
|
||||||
|
Pid int64
|
||||||
|
Uid string
|
||||||
|
Value claim.Value
|
||||||
|
TS uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeResult is the merged value of one key.
|
||||||
|
type MergeResult struct {
|
||||||
|
Value string
|
||||||
|
Nsrc int // signers agreeing on the winning value
|
||||||
|
OK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge is the one merge rule (spec §7), shared by the apply loop, the
|
||||||
|
// ?as=<uid> overlay and POST /api/merge:
|
||||||
|
//
|
||||||
|
// 1. a signer counts once per key: its newest vote wins (it may have voted on
|
||||||
|
// several cids of the group);
|
||||||
|
// 2. the viewer's own live text vote wins outright (viewer == "" for the
|
||||||
|
// shared view);
|
||||||
|
// 3. retractions and ciphertext drop out — only text merges;
|
||||||
|
// 4. support: votes grouped by normalised value, most signers wins;
|
||||||
|
// 5. tie → the value first seen (oldest ts) — stable, and rewriting does not
|
||||||
|
// win;
|
||||||
|
// 6. tie → byte-wise smallest value, so every peer agrees.
|
||||||
|
//
|
||||||
|
// The stored value is the exact text of the oldest vote of the winning group.
|
||||||
|
func Merge(votes []Vote, viewer string) MergeResult {
|
||||||
|
newest := make(map[string]Vote, len(votes))
|
||||||
|
for _, v := range votes {
|
||||||
|
id := signerID(v)
|
||||||
|
if cur, ok := newest[id]; !ok || v.TS > cur.TS {
|
||||||
|
newest[id] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type bucket struct {
|
||||||
|
n int
|
||||||
|
minTS uint64
|
||||||
|
exemplar string
|
||||||
|
}
|
||||||
|
buckets := make(map[string]*bucket)
|
||||||
|
for _, v := range newest {
|
||||||
|
if v.Value.Kind != claim.KindText {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if viewer != "" && v.Uid == viewer {
|
||||||
|
return MergeResult{Value: v.Value.Text, Nsrc: 1, OK: true}
|
||||||
|
}
|
||||||
|
norm := normalizeValue(v.Value.Text)
|
||||||
|
b := buckets[norm]
|
||||||
|
if b == nil {
|
||||||
|
buckets[norm] = &bucket{n: 1, minTS: v.TS, exemplar: v.Value.Text}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.n++
|
||||||
|
if v.TS < b.minTS || (v.TS == b.minTS && v.Value.Text < b.exemplar) {
|
||||||
|
b.minTS, b.exemplar = v.TS, v.Value.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var best *bucket
|
||||||
|
for _, b := range buckets {
|
||||||
|
switch {
|
||||||
|
case best == nil,
|
||||||
|
b.n > best.n,
|
||||||
|
b.n == best.n && b.minTS < best.minTS,
|
||||||
|
b.n == best.n && b.minTS == best.minTS && b.exemplar < best.exemplar:
|
||||||
|
best = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == nil {
|
||||||
|
return MergeResult{}
|
||||||
|
}
|
||||||
|
return MergeResult{Value: best.exemplar, Nsrc: best.n, OK: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signerID(v Vote) string {
|
||||||
|
if v.Uid != "" {
|
||||||
|
return v.Uid
|
||||||
|
}
|
||||||
|
return "#" + strconv.FormatInt(v.Pid, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeValue folds case and surrounding whitespace so "The Matrix" and
|
||||||
|
// "the matrix " count as the same value. (Unicode NFC folding is not applied:
|
||||||
|
// the module carries no normalisation table; values written by one plugin
|
||||||
|
// family are already consistent.)
|
||||||
|
func normalizeValue(s string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
@@ -2,82 +2,75 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/metazla/meta-core/internal/kv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SchemaVersion is the Redis-layout version this build of meta-core expects.
|
// SchemaVersion is the truth-database layout this build of meta-core expects.
|
||||||
// Distinct from the snapshot manifest version (which is on-disk format);
|
|
||||||
// this number describes the in-memory Redis schema — primarily, "are roots
|
|
||||||
// UUIDs or content-hash tokens, and do reverse-index keys cid:* exist?"
|
|
||||||
//
|
//
|
||||||
// Bump only when a Redis-layout change can't be detected from the data
|
// 2 — v1.x: one kv table holding records, the CID index and UDL cells
|
||||||
// itself or healed by the auto-alias hooks. Each bump is a flag day in
|
// 3 — v2.0.0: the claims model (truth: principal, claim, cid_group, meta)
|
||||||
// alpha: operators wipe and restart.
|
//
|
||||||
const SchemaVersion = 2
|
// Bump only when a layout change can't be detected from the data itself. Each
|
||||||
|
// bump is a flag day: there is no migration code in meta-core (the one-off
|
||||||
|
// v2 → v3 conversion of watch.nsl.sh is a manual tool, tools/claims-migrate).
|
||||||
|
const SchemaVersion = 3
|
||||||
|
|
||||||
// SchemaVersionKey is the sentinel under which we record SchemaVersion in
|
// SchemaVersionKey is the truth `meta` row recording SchemaVersion.
|
||||||
// Redis. Checked on every meta-core boot.
|
const SchemaVersionKey = "schema-version"
|
||||||
const SchemaVersionKey = "meta-core:schema-version"
|
|
||||||
|
|
||||||
// EnsureSchemaVersion verifies the Redis store matches the expected schema
|
// EnsureSchemaVersion verifies the truth database matches the expected
|
||||||
// version. Called once at startup, before any other reads/writes.
|
// version. Called once at startup, before Start.
|
||||||
//
|
//
|
||||||
// Behavior:
|
// - a v1.x store (the old kv table, which the default path still points at)
|
||||||
// - sentinel present and matches expected → OK
|
// → refuse
|
||||||
// - sentinel missing AND file:__index__ empty → fresh store, stamp sentinel
|
// - sentinel present and matching → OK
|
||||||
// - sentinel missing AND file:__index__ non-empty → legacy data, refuse
|
// - sentinel missing and no claims → fresh store, stamp it
|
||||||
// - sentinel present but mismatched → refuse, point operator at clean:all
|
// - sentinel missing with claims, or mismatched → refuse
|
||||||
//
|
|
||||||
// Refusal is hard-fail; the caller is expected to log.Fatalf and exit.
|
|
||||||
// The error message names the wipe remediation because in alpha there
|
|
||||||
// is no automated migration — operators bring up a clean store.
|
|
||||||
func (c *Client) EnsureSchemaVersion(expected int) error {
|
func (c *Client) EnsureSchemaVersion(expected int) error {
|
||||||
c.mu.Lock()
|
c.mu.RLock()
|
||||||
defer c.mu.Unlock()
|
truth := c.truth
|
||||||
|
c.mu.RUnlock()
|
||||||
if c.client == nil {
|
if truth == nil {
|
||||||
return fmt.Errorf("not connected")
|
return fmt.Errorf("not connected")
|
||||||
}
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
return truth.Update(ctx, func(tx *kv.Tx) error {
|
||||||
key := c.buildKey(SchemaVersionKey)
|
var name string
|
||||||
have, err := c.client.Get(ctx, key).Result()
|
err := tx.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'kv'`).Scan(&name)
|
||||||
if err != nil && err != redis.Nil {
|
if err == nil {
|
||||||
return fmt.Errorf("get schema version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == redis.Nil {
|
|
||||||
// Sentinel absent. Fresh store iff there's no file:__index__ membership.
|
|
||||||
n, sErr := c.client.SCard(ctx, c.buildIndexKey()).Result()
|
|
||||||
if sErr != nil && sErr != redis.Nil {
|
|
||||||
return fmt.Errorf("scard file:__index__: %w", sErr)
|
|
||||||
}
|
|
||||||
if n > 0 {
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"redis has %d indexed roots but no %q sentinel — this is alpha "+
|
"%s holds a meta-core v1.x store (schema version 2, the kv table). v2.0.0 "+
|
||||||
"data from before the UUID-rooted schema landed. Run "+
|
"does not migrate it: convert it with tools/claims-migrate, or move it "+
|
||||||
"`pnpm run clean:all` (or `docker exec metacore-app redis-cli FLUSHDB`) "+
|
"away to start empty", truth.Path())
|
||||||
"to wipe and restart, or downgrade meta-core to a compatible build",
|
|
||||||
n, SchemaVersionKey)
|
|
||||||
}
|
}
|
||||||
if err := c.client.Set(ctx, key, strconv.Itoa(expected), 0).Err(); err != nil {
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
return fmt.Errorf("set schema version: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
var have string
|
||||||
}
|
err = tx.QueryRow(`SELECT value FROM meta WHERE name = ?`, SchemaVersionKey).Scan(&have)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
if have == strconv.Itoa(expected) {
|
var n int64
|
||||||
return nil
|
if err := tx.QueryRow(`SELECT count(*) FROM claim`).Scan(&n); err != nil {
|
||||||
}
|
return err
|
||||||
return fmt.Errorf(
|
}
|
||||||
"redis schema version is %q, this build of meta-core expects %d. "+
|
if n > 0 {
|
||||||
"Run `pnpm run clean:all` to wipe and restart, or use a meta-core "+
|
return fmt.Errorf("truth database has %d claims but no %q sentinel; refusing to guess its layout", n, SchemaVersionKey)
|
||||||
"build that matches the existing data",
|
}
|
||||||
have, expected)
|
_, err := tx.Exec(`INSERT INTO meta(name, value) VALUES(?, ?)`, SchemaVersionKey, strconv.Itoa(expected))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get schema version: %w", err)
|
||||||
|
}
|
||||||
|
if have == strconv.Itoa(expected) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("truth schema version is %q, this build of meta-core expects %d", have, expected)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,637 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
// Search over the KV store.
|
||||||
|
//
|
||||||
|
// The Redis era answered POST /api/metadata/search from an in-memory copy of
|
||||||
|
// every record (the API's searchIndex), because Redis could not filter
|
||||||
|
// server-side. That copy cost ~30 KB of RAM per record (5.1 GB at 169k records
|
||||||
|
// on watch.nsl.sh) and was the reason meta-core kept getting OOM-killed. It is
|
||||||
|
// gone: search now runs against two derived structures inside the database.
|
||||||
|
//
|
||||||
|
// - kv_field_v — an expression index over (lowercased field name parsed out
|
||||||
|
// of the key, lowercased value, value), covering only record keys. It answers "which
|
||||||
|
// records have field F with a value like V" for ANY field, including ones
|
||||||
|
// invented tomorrow: the data stays schema-free, the index is over the key
|
||||||
|
// itself.
|
||||||
|
// - fts — an FTS5 trigram table holding one row per record: the record's
|
||||||
|
// haystack (buildHaystack) plus its rid. Trigrams make every substring of
|
||||||
|
// 3+ characters indexable, which is the matching rule search always had.
|
||||||
|
//
|
||||||
|
// SQL produces a SUPERSET of candidates; the caller verifies each hydrated
|
||||||
|
// record with the exact Go matchers (api.recordMatchesFilters et al.), so the
|
||||||
|
// semantics are bit-identical to the in-memory matcher and nothing depends on
|
||||||
|
// SQLite's ASCII-only lower() agreeing with Go's Unicode folding.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/kv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The field expression and the partial-index predicate MUST appear verbatim
|
||||||
|
// (as literals, never bound parameters) in every query meant to use kv_field_v —
|
||||||
|
// SQLite only matches a partial index whose WHERE clause it can see implied by
|
||||||
|
// the query text. TestSearchFilterUsesFieldIndex pins this.
|
||||||
|
const (
|
||||||
|
fieldExpr = `lower(substr(k, instr(k,'/')+1))`
|
||||||
|
recordPred = `k >= 'file:' AND k < 'file;' AND instr(k,'/') > 0`
|
||||||
|
ridExpr = `substr(k, 6, instr(k,'/')-6)`
|
||||||
|
)
|
||||||
|
|
||||||
|
// searchSchema is the derived DDL applied when the database is opened.
|
||||||
|
//
|
||||||
|
// kv_field_v carries the raw value as a third column so the filters' substring
|
||||||
|
// test (instr(lower(v), …)) is answered from the index alone. Without it,
|
||||||
|
// SQLite fetched the table row for every entry of the field: 1.48 s vs 13 ms
|
||||||
|
// for tmdbid (110k entries) on watch.nsl.sh. It replaced kv_field (field,
|
||||||
|
// lower(v)), which is dropped here; the rebuild costs ~80 s once.
|
||||||
|
const searchSchema = `
|
||||||
|
DROP INDEX IF EXISTS kv_field;
|
||||||
|
CREATE INDEX IF NOT EXISTS kv_field_v ON kv(` + fieldExpr + `, lower(v), v) WHERE ` + recordPred + `;
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(rid UNINDEXED, hay, tokenize='trigram');
|
||||||
|
`
|
||||||
|
|
||||||
|
// searchIndexFields are the metadata fields folded into a record's search
|
||||||
|
// haystack — must stay in sync with the fields the search handler documents.
|
||||||
|
var searchIndexFields = []string{"title", "fileName", "originaltitle", "showtitle", "filePath"}
|
||||||
|
|
||||||
|
// affectsHaystack reports whether writing field can change the haystack.
|
||||||
|
func affectsHaystack(field string) bool {
|
||||||
|
if strings.HasPrefix(field, "titles/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, f := range searchIndexFields {
|
||||||
|
if field == f {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Haystack folds a record's searchable text fields into one lowercased blob —
|
||||||
|
// what free-text tokens must each be a substring of.
|
||||||
|
//
|
||||||
|
// Beyond the flat fields it folds in every `titles/<lang3>/<name>` member's
|
||||||
|
// name (METADATA_KEYS.md §3): the name lives in the key and the value is the
|
||||||
|
// sentinel "true", so a search for an alternative title (`3 percent`) finds the
|
||||||
|
// record whose `title` is `3%`.
|
||||||
|
func Haystack(fields map[string]string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, f := range searchIndexFields {
|
||||||
|
if v, ok := fields[f]; ok && v != "" {
|
||||||
|
sb.WriteString(strings.ToLower(v))
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range titleMemberNames(fields) {
|
||||||
|
sb.WriteString(strings.ToLower(name))
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// titleMemberNames returns the names of a record's `titles/<lang3>/<name>`
|
||||||
|
// key-set members, with the `∕` (U+2215) a writer substitutes for `/` mapped
|
||||||
|
// back. Sorted so the haystack is deterministic.
|
||||||
|
func titleMemberNames(fields map[string]string) []string {
|
||||||
|
var names []string
|
||||||
|
for k, v := range fields {
|
||||||
|
rest, ok := strings.CutPrefix(k, "titles/")
|
||||||
|
if !ok || v == "" || v == "false" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, name, ok := strings.Cut(rest, "/")
|
||||||
|
if !ok || name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, strings.ReplaceAll(name, "∕", "/"))
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// ftsHay is what the FTS row indexes: the haystack plus the rid, so the
|
||||||
|
// "query is a substring of the hashID" rule also has an indexed candidate path.
|
||||||
|
// Verification checks the two separately (a rid token plus a title token must
|
||||||
|
// not match together).
|
||||||
|
func ftsHay(hashID string, fields map[string]string) string {
|
||||||
|
return Haystack(fields) + strings.ToLower(hashID) + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshSearchTx rewrites a record's FTS row from its current fields, or
|
||||||
|
// drops it when the record is gone.
|
||||||
|
func refreshSearchTx(tx *kv.Tx, hashID string) error {
|
||||||
|
fields, err := readRecord(tx.Context(), tx, hashID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return deleteSearchRowTx(tx, hashID)
|
||||||
|
}
|
||||||
|
return upsertSearchRowTx(tx, hashID, ftsHay(hashID, fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSearchRowTx creates the record's FTS row if it has none (a record
|
||||||
|
// whose writes never touched a haystack field still needs one for the rid
|
||||||
|
// rule).
|
||||||
|
func ensureSearchRowTx(tx *kv.Tx, hashID string) error {
|
||||||
|
_, ok, err := tx.Get(tx.Context(), ftsNS+hashID)
|
||||||
|
if err != nil || ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return refreshSearchTx(tx, hashID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertSearchRowTx(tx *kv.Tx, hashID, hay string) error {
|
||||||
|
rowidStr, ok, err := tx.Get(tx.Context(), ftsNS+hashID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
if _, err := tx.Exec(`UPDATE fts SET hay = ? WHERE rowid = ?`, hay, rowidStr); err != nil {
|
||||||
|
return fmt.Errorf("fts update %s: %w", hashID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(`INSERT INTO fts(rid, hay) VALUES(?, ?)`, hashID, hay)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fts insert %s: %w", hashID, err)
|
||||||
|
}
|
||||||
|
rowid, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Put(ftsNS+hashID, strconv.FormatInt(rowid, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSearchRowTx(tx *kv.Tx, hashID string) error {
|
||||||
|
rowidStr, ok, err := tx.Get(tx.Context(), ftsNS+hashID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DELETE FROM fts WHERE rowid = ?`, rowidStr); err != nil {
|
||||||
|
return fmt.Errorf("fts delete %s: %w", hashID, err)
|
||||||
|
}
|
||||||
|
_, err = tx.Delete(ftsNS + hashID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchFilter is one structured filter: AND across filters, OR across Values.
|
||||||
|
type SearchFilter struct {
|
||||||
|
Property string
|
||||||
|
Values []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchHit is one verified result.
|
||||||
|
type SearchHit struct {
|
||||||
|
HashID string
|
||||||
|
Fields map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search returns up to limit records, in hashID order, that satisfy match.
|
||||||
|
//
|
||||||
|
// query and filters only NARROW the candidates (SQL, superset semantics);
|
||||||
|
// match is the authoritative test, run on each hydrated record. Pass the same
|
||||||
|
// query/filters match evaluates, or results will be missing.
|
||||||
|
func (c *Client) Search(query string, filters []SearchFilter, limit int, match func(hashID string, fields map[string]string) bool) ([]SearchHit, error) {
|
||||||
|
return c.SearchAs(query, filters, limit, "", match)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAs is Search seen by viewer (a uid, "" for the shared view): every
|
||||||
|
// candidate is matched with the viewer's own claims overlaid, and the records
|
||||||
|
// the viewer holds claims on are candidates too, so their own corrections are
|
||||||
|
// searchable for them.
|
||||||
|
func (c *Client) SearchAs(query string, filters []SearchFilter, limit int, viewer string, match func(hashID string, fields map[string]string) bool) ([]SearchHit, error) {
|
||||||
|
s, err := c.kv()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
plan, err := c.planCandidates(ctx, s, query, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
page := max(limit*2, 500)
|
||||||
|
hits := make([]SearchHit, 0, limit)
|
||||||
|
after := ""
|
||||||
|
for pos := 0; len(hits) < limit; {
|
||||||
|
var ids []string
|
||||||
|
if plan.walk {
|
||||||
|
rows, err := s.Range(ctx, rootsNS, after, page)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
ids = append(ids, row.K[len(rootsNS):])
|
||||||
|
}
|
||||||
|
if len(rows) > 0 {
|
||||||
|
after = rows[len(rows)-1].K
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ids = plan.ordered[pos:min(pos+page, len(plan.ordered))]
|
||||||
|
pos += len(ids)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
survivors := ids
|
||||||
|
if len(plan.probes) > 0 {
|
||||||
|
if survivors, err = probeIDs(ctx, s, ids, plan.probes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range survivors {
|
||||||
|
fields, err := c.readRecordAs(ctx, s, id, viewer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if fields == nil || !match(id, fields) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hits = append(hits, SearchHit{HashID: id, Fields: fields})
|
||||||
|
if len(hits) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) < page {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if viewer == "" {
|
||||||
|
return hits, nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(hits))
|
||||||
|
for _, h := range hits {
|
||||||
|
seen[h.HashID] = true
|
||||||
|
}
|
||||||
|
own, err := c.viewerRIDs(ctx, viewer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, id := range own {
|
||||||
|
if seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields, err := c.readRecordAs(ctx, s, id, viewer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if fields != nil && match(id, fields) {
|
||||||
|
hits = append(hits, SearchHit{HashID: id, Fields: fields})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(hits, func(i, j int) bool { return hits[i].HashID < hits[j].HashID })
|
||||||
|
if len(hits) > limit {
|
||||||
|
hits = hits[:limit]
|
||||||
|
}
|
||||||
|
return hits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeSQL applies the non-driving filters to one page of candidate rids
|
||||||
|
// (passed as a JSON array) — each an EXISTS over the candidate's own key range.
|
||||||
|
func probeSQL(probes []candidatePart) (string, []any) {
|
||||||
|
where := make([]string, len(probes))
|
||||||
|
var args []any
|
||||||
|
for i, p := range probes {
|
||||||
|
where[i] = p.exists
|
||||||
|
args = append(args, p.existsArgs...)
|
||||||
|
}
|
||||||
|
return `WITH c(rid) AS (SELECT value FROM json_each(?)) SELECT rid FROM c WHERE ` + strings.Join(where, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeIDs keeps the ids (in their order) that pass every probe.
|
||||||
|
func probeIDs(ctx context.Context, s *kv.Store, ids []string, probes []candidatePart) ([]string, error) {
|
||||||
|
q, args := probeSQL(probes)
|
||||||
|
list, err := json.Marshal(ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.DB().QueryContext(ctx, q, append([]any{string(list)}, args...)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search probes: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
pass := make(map[string]bool, len(ids))
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pass[id] = true
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if pass[id] {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidatePart is one narrowing condition: free text, or one filter.
|
||||||
|
type candidatePart struct {
|
||||||
|
sql string // SELECT … AS rid
|
||||||
|
args []any
|
||||||
|
// exists is the same filter as a predicate on candidate row c.rid (empty
|
||||||
|
// for free text, which has no per-record probe: the FTS rid column is not
|
||||||
|
// indexed).
|
||||||
|
exists string
|
||||||
|
existsArgs []any
|
||||||
|
}
|
||||||
|
|
||||||
|
// estimateCap bounds the count used to rank parts by selectivity: past it, a
|
||||||
|
// part is "broad" and only ties with other broad parts are left to resolve.
|
||||||
|
var estimateCap = 50_000
|
||||||
|
|
||||||
|
// estimateCapForTest sets estimateCap and returns the previous value.
|
||||||
|
func estimateCapForTest(n int) int { old := estimateCap; estimateCap = n; return old }
|
||||||
|
|
||||||
|
// searchPlan is how one search walks its candidates.
|
||||||
|
type searchPlan struct {
|
||||||
|
ordered []string // the driver's rids, sorted (when !walk)
|
||||||
|
walk bool // no part narrows: walk idx:roots in order
|
||||||
|
probes []candidatePart // filters checked per page as EXISTS
|
||||||
|
}
|
||||||
|
|
||||||
|
// planCandidates picks how to narrow a search.
|
||||||
|
//
|
||||||
|
// A capped count ranks the parts; the SMALLEST is collected once, sorted in Go
|
||||||
|
// and paged from memory, and every other filter becomes a probe — an EXISTS
|
||||||
|
// over each candidate's own key range, run one page at a time, so a search
|
||||||
|
// stops as soon as `limit` records verify. The driver is collected in full even
|
||||||
|
// when every part is broad: that costs O(part) — at most every root, ~0.3 s at
|
||||||
|
// 169k — whereas walking idx:roots in hashID order (tried) costs O(corpus) when
|
||||||
|
// matches are sparse early in that order, and on watch.nsl.sh they are: the
|
||||||
|
// first 500 roots held zero episodes, so `contentKind:episode` walked ~3 s.
|
||||||
|
// Only a search with nothing to narrow walks the roots, where every root
|
||||||
|
// matches.
|
||||||
|
//
|
||||||
|
// Also rejected, measured on watch.nsl.sh: INTERSECTing fully materialised
|
||||||
|
// sets (5.7 s for `naruto` + `fileType:video`); probing inside a driver query
|
||||||
|
// re-sorted per page (SQLite evaluates the probe on every driver row before
|
||||||
|
// ORDER BY … LIMIT can stop: 10 s for `1080p` + `fileType:video`). Free text
|
||||||
|
// that does not drive is dropped from SQL — the verifier still applies it, and
|
||||||
|
// dropping a condition only widens the superset.
|
||||||
|
func (c *Client) planCandidates(ctx context.Context, s *kv.Store, query string, filters []SearchFilter) (searchPlan, error) {
|
||||||
|
var parts []candidatePart
|
||||||
|
if p, ok := textCandidates(query); ok {
|
||||||
|
parts = append(parts, p)
|
||||||
|
}
|
||||||
|
for _, f := range filters {
|
||||||
|
if p, ok := filterCandidates(f); ok {
|
||||||
|
parts = append(parts, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return searchPlan{walk: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
driver := 0
|
||||||
|
if len(parts) > 1 {
|
||||||
|
best := int64(-1)
|
||||||
|
for i, p := range parts {
|
||||||
|
var n int64
|
||||||
|
q := `SELECT count(*) FROM (SELECT rid FROM (` + p.sql + `) LIMIT ` + strconv.Itoa(estimateCap+1) + `)`
|
||||||
|
if err := s.DB().QueryRowContext(ctx, q, p.args...).Scan(&n); err != nil {
|
||||||
|
return searchPlan{}, fmt.Errorf("estimate candidates: %w", err)
|
||||||
|
}
|
||||||
|
if best < 0 || n < best {
|
||||||
|
best, driver = n, i
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
break // nothing can match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids, err := collectIDs(ctx, s, parts[driver], 0)
|
||||||
|
if err != nil {
|
||||||
|
return searchPlan{}, err
|
||||||
|
}
|
||||||
|
sort.Strings(ids)
|
||||||
|
plan := searchPlan{ordered: ids}
|
||||||
|
for i, p := range parts {
|
||||||
|
if i != driver && p.exists != "" {
|
||||||
|
plan.probes = append(plan.probes, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectIDs reads the distinct rids of one part (all of them when limit <= 0).
|
||||||
|
func collectIDs(ctx context.Context, s *kv.Store, p candidatePart, limit int) ([]string, error) {
|
||||||
|
q := `SELECT DISTINCT rid FROM (` + p.sql + `)`
|
||||||
|
args := append([]any{}, p.args...)
|
||||||
|
if limit > 0 {
|
||||||
|
q += ` LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
}
|
||||||
|
rows, err := s.DB().QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search candidates: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateSQL is the first part's SQL, for tests that inspect the plan.
|
||||||
|
func candidateSQL(query string, filters []SearchFilter) (string, []any) {
|
||||||
|
if p, ok := textCandidates(query); ok {
|
||||||
|
return p.sql, p.args
|
||||||
|
}
|
||||||
|
for _, f := range filters {
|
||||||
|
if p, ok := filterCandidates(f); ok {
|
||||||
|
return p.sql, p.args
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// textCandidates narrows by free text: every whitespace token must be a
|
||||||
|
// substring of the FTS hay (haystack + rid). Tokens of 3+ runes go through the
|
||||||
|
// trigram index; shorter ones (trigrams cannot index them) are a LIKE on the
|
||||||
|
// same rows.
|
||||||
|
func textCandidates(query string) (candidatePart, bool) {
|
||||||
|
tokens := strings.Fields(strings.ToLower(query))
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
return candidatePart{}, false
|
||||||
|
}
|
||||||
|
var match []string
|
||||||
|
var likes []string
|
||||||
|
var likeArgs []any
|
||||||
|
for _, t := range tokens {
|
||||||
|
if utf8.RuneCountInString(t) >= 3 {
|
||||||
|
match = append(match, `"`+strings.ReplaceAll(t, `"`, `""`)+`"`)
|
||||||
|
} else {
|
||||||
|
likes = append(likes, `hay LIKE ? ESCAPE '\'`)
|
||||||
|
likeArgs = append(likeArgs, "%"+escapeLike(t)+"%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var where []string
|
||||||
|
var args []any
|
||||||
|
if len(match) > 0 {
|
||||||
|
where = append(where, `fts MATCH ?`)
|
||||||
|
args = append(args, strings.Join(match, " "))
|
||||||
|
}
|
||||||
|
where = append(where, likes...)
|
||||||
|
args = append(args, likeArgs...)
|
||||||
|
return candidatePart{sql: `SELECT rid FROM fts WHERE ` + strings.Join(where, " AND "), args: args}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeLike(s string) string {
|
||||||
|
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterCandidates narrows by one filter — the UNION, over its values, of the
|
||||||
|
// two storage shapes recordMatchesFilter probes:
|
||||||
|
//
|
||||||
|
// - key-set member `<prop>/<value>` (any case): the lowercased field equals
|
||||||
|
// lower(prop/value); for a non-ASCII value, any member of prop (SQLite's
|
||||||
|
// lower() only folds ASCII, Go's folds Unicode, so an exact lowercase
|
||||||
|
// comparison could miss);
|
||||||
|
// - flat field `<prop>` whose lowercased value contains the lowercased value
|
||||||
|
// — covers exact and contains alike, and comma-split csv-set values.
|
||||||
|
//
|
||||||
|
// ok=false means "this filter cannot narrow" (blank, or a non-ASCII property
|
||||||
|
// name): it constrains nothing in SQL and is left to the verifier.
|
||||||
|
func filterCandidates(f SearchFilter) (candidatePart, bool) {
|
||||||
|
var values []string
|
||||||
|
for _, v := range f.Values {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
values = append(values, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Property == "" || len(values) == 0 || !isASCII(f.Property) {
|
||||||
|
return candidatePart{}, false
|
||||||
|
}
|
||||||
|
prop := asciiLower(f.Property)
|
||||||
|
var conds []string
|
||||||
|
var args []any
|
||||||
|
memberRange := false
|
||||||
|
for _, v := range values {
|
||||||
|
if isASCII(v) {
|
||||||
|
lv := asciiLower(v)
|
||||||
|
conds = append(conds, fieldExpr+` = ?`)
|
||||||
|
args = append(args, prop+"/"+lv)
|
||||||
|
conds = append(conds, fieldExpr+` = ? AND instr(lower(v), ?) > 0`)
|
||||||
|
args = append(args, prop, lv)
|
||||||
|
} else {
|
||||||
|
memberRange = true
|
||||||
|
conds = append(conds, fieldExpr+` = ?`)
|
||||||
|
args = append(args, prop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if memberRange {
|
||||||
|
conds = append(conds, fieldExpr+` >= ? AND `+fieldExpr+` < ?`)
|
||||||
|
args = append(args, prop+"/", kv.PrefixEnd(prop+"/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// As a driver: one indexed SELECT per condition, joined with UNION ALL (an
|
||||||
|
// OR inside one SELECT would defeat the partial index; plain UNION would
|
||||||
|
// dedupe by materialising every row first — 1.8 s vs 0.25 s for a capped
|
||||||
|
// count over fileType:video on watch.nsl.sh). collectIDs applies DISTINCT.
|
||||||
|
base := `SELECT ` + ridExpr + ` AS rid FROM kv WHERE ` + recordPred + ` AND `
|
||||||
|
selects := make([]string, len(conds))
|
||||||
|
for i, cond := range conds {
|
||||||
|
selects[i] = base + cond
|
||||||
|
}
|
||||||
|
// As a probe: the same conditions over one candidate's key range.
|
||||||
|
exists := `EXISTS (SELECT 1 FROM kv WHERE k >= 'file:' || c.rid || '/' AND k < 'file:' || c.rid || '0' AND ((` +
|
||||||
|
strings.Join(conds, ") OR (") + `)))`
|
||||||
|
return candidatePart{
|
||||||
|
sql: strings.Join(selects, " UNION ALL "), args: args,
|
||||||
|
exists: exists, existsArgs: args,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCII(s string) bool {
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] >= utf8.RuneSelf {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// asciiLower lowercases A–Z only, exactly like SQLite's lower().
|
||||||
|
func asciiLower(s string) string {
|
||||||
|
b := []byte(s)
|
||||||
|
for i, c := range b {
|
||||||
|
if 'A' <= c && c <= 'Z' {
|
||||||
|
b[i] = c + ('a' - 'A')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(name string, def int) int {
|
||||||
|
if v, err := strconv.Atoi(os.Getenv(name)); err == nil && v > 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebuildSearchIfEmpty rebuilds every record's FTS row when the FTS table is
|
||||||
|
// empty but records exist — a store restored from a snapshot, a copy made
|
||||||
|
// outside meta-core (the 2026-10 Redis export), or an FTS table dropped to
|
||||||
|
// change its tokenizer. The rows are derived, so this is a repair, not a
|
||||||
|
// migration. Runs in bounded batches so writes keep flowing; until it
|
||||||
|
// finishes, free-text search under-matches (filters are unaffected).
|
||||||
|
func (c *Client) RebuildSearchIfEmpty() (int, error) {
|
||||||
|
s, err := c.kv()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
|
var rows int64
|
||||||
|
err = s.DB().QueryRowContext(ctx, `SELECT count(*) FROM fts`).Scan(&rows)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count fts rows: %w", err)
|
||||||
|
}
|
||||||
|
if rows > 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
ids, err := c.GetAllHashIDs()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
const batch = 500
|
||||||
|
for start := 0; start < len(ids); start += batch {
|
||||||
|
chunk := ids[start:min(start+batch, len(ids))]
|
||||||
|
if err := c.update(func(tx *kv.Tx) error {
|
||||||
|
for _, id := range chunk {
|
||||||
|
if err := refreshSearchTx(tx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return start, fmt.Errorf("rebuild search rows: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(ids), nil
|
||||||
|
}
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Incremental search-index maintenance.
|
|
||||||
//
|
|
||||||
// # Why this exists
|
|
||||||
//
|
|
||||||
// The API's in-memory search index used to be rebuilt in full every 60 s:
|
|
||||||
// GetSearchIndexTuples does a whole-keyspace SCAN plus batched MGETs, builds an
|
|
||||||
// intermediate map of every record, then allocates a fresh []SearchTuple — all
|
|
||||||
// while the previous index is still live serving reads. That cost is O(corpus),
|
|
||||||
// not O(change): adding 200 records in a minute re-read every one of the other
|
|
||||||
// ~620 000 field keys to notice them.
|
|
||||||
//
|
|
||||||
// Measured on a 19 622-record staging corpus (2026-08-30): SCAN was 57.8 % and
|
|
||||||
// MGET 19.1 % of ALL Redis CPU, and ~90 % of the MGET calls plus ~48 % of the
|
|
||||||
// SCAN calls were this loop — roughly 45 % of Redis's total work, spent
|
|
||||||
// continuously whether or not anything changed. The Go side held 783 MB for
|
|
||||||
// data Redis stores in 135 MB, largely because of the double copy.
|
|
||||||
//
|
|
||||||
// That was tolerable while the corpus was small and mostly static. It stops
|
|
||||||
// being tolerable now that meta-core is the platform's permanent, additive
|
|
||||||
// store: every search hit on every gateway is retained forever, so the rebuild
|
|
||||||
// cost is a ratchet.
|
|
||||||
//
|
|
||||||
// # The shape
|
|
||||||
//
|
|
||||||
// Every write path marks its record in a dirty set; the refresh drains that set
|
|
||||||
// and re-reads only those records. Cost becomes O(change).
|
|
||||||
//
|
|
||||||
// Marking rides along with the field-index maintenance every write path already
|
|
||||||
// performs, so there is no new bookkeeping discipline to remember — if you are
|
|
||||||
// adding a write path, the same line that keeps `__fields__` correct keeps this
|
|
||||||
// correct.
|
|
||||||
//
|
|
||||||
// # Correctness
|
|
||||||
//
|
|
||||||
// The drain is a destructive SPOP, so a write that lands *during* a refresh is
|
|
||||||
// either already in the drained batch or still in the set for the next tick —
|
|
||||||
// never dropped. The cost of the race is one tick of staleness, which is well
|
|
||||||
// inside the interval the index was already stale by.
|
|
||||||
//
|
|
||||||
// A refresh that fails after draining WOULD lose its batch, so drainDirty's
|
|
||||||
// caller re-marks on error (see MarkDirty / the API's refreshIncremental).
|
|
||||||
//
|
|
||||||
// ⚠ **The destructive drain assumes ONE index consumer per Redis.** Two
|
|
||||||
// meta-core processes sharing a keyspace would steal each other's marks, and
|
|
||||||
// each would end up with an index missing whatever the other drained. That is
|
|
||||||
// fine today — multi-instance leader election was deprecated and the dev/prod
|
|
||||||
// stacks run a single meta-core — but it is the assumption to revisit first if
|
|
||||||
// a second instance is ever pointed at the same Redis. A per-consumer dirty key
|
|
||||||
// (`file:__dirty__:<consumer>`) is the shape that would fix it.
|
|
||||||
//
|
|
||||||
// The API's periodic full reconcile (searchIndexReconcileEvery) is the backstop
|
|
||||||
// for both this and for a write path that forgets to mark: a missed mark costs
|
|
||||||
// staleness until the next reconcile, never permanent divergence.
|
|
||||||
|
|
||||||
// dirtyField is the reserved key holding the set of record ids whose metadata
|
|
||||||
// changed since the last search-index refresh. It sits under the same `file:`
|
|
||||||
// namespace as `__index__` and is likewise not a record.
|
|
||||||
const dirtyField = "file:__dirty__"
|
|
||||||
|
|
||||||
// dirtyDrainCap bounds one incremental refresh. A burst larger than this is
|
|
||||||
// drained over several ticks rather than in one long-held pass — the point of
|
|
||||||
// the change is to keep any single refresh cheap. Sized well above a normal
|
|
||||||
// search-persist flush (which writes at most a few hundred records).
|
|
||||||
const dirtyDrainCap = 5000
|
|
||||||
|
|
||||||
// buildDirtyKey constructs the key for the dirty-record set.
|
|
||||||
func (c *Client) buildDirtyKey() string {
|
|
||||||
return c.buildKey(dirtyField)
|
|
||||||
}
|
|
||||||
|
|
||||||
// markDirtyLocked records that hashID's metadata changed. Caller must hold c.mu.
|
|
||||||
//
|
|
||||||
// Best-effort by design: a failure here costs one record its incremental
|
|
||||||
// refresh, and the periodic full rebuild still repairs it. Failing the caller's
|
|
||||||
// write instead would trade a stale index entry for a lost write, which is
|
|
||||||
// strictly worse.
|
|
||||||
func (c *Client) markDirtyLocked(ctx context.Context, hashID string) {
|
|
||||||
if hashID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := c.client.SAdd(ctx, c.buildDirtyKey(), hashID).Err(); err != nil {
|
|
||||||
log.Printf("[STORAGE] dirty-mark failed for %s: %v (index repairs on the next full rebuild)", hashID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkDirty records that hashID needs re-indexing. Exported for the API layer,
|
|
||||||
// which re-marks a batch whose refresh failed after the drain.
|
|
||||||
func (c *Client) MarkDirty(hashIDs ...string) {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
if c.client == nil || len(hashIDs) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
members := make([]interface{}, 0, len(hashIDs))
|
|
||||||
for _, h := range hashIDs {
|
|
||||||
if h != "" {
|
|
||||||
members = append(members, h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(members) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := c.client.SAdd(ctx, c.buildDirtyKey(), members...).Err(); err != nil {
|
|
||||||
log.Printf("[STORAGE] dirty re-mark failed for %d records: %v", len(members), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DrainDirty removes and returns up to dirtyDrainCap record ids from the dirty
|
|
||||||
// set. `more` reports whether entries remain, so the caller can schedule
|
|
||||||
// another pass instead of waiting a full interval to catch up on a burst.
|
|
||||||
//
|
|
||||||
// Destructive on purpose: SPOP makes concurrent refreshes disjoint and stops a
|
|
||||||
// record being re-read every tick forever. The caller owns the drained ids
|
|
||||||
// until it has indexed them — on failure it must MarkDirty them again.
|
|
||||||
func (c *Client) DrainDirty() (ids []string, more bool, err error) {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return nil, false, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
key := c.buildDirtyKey()
|
|
||||||
ids, err = c.client.SPopN(ctx, key, dirtyDrainCap).Result()
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, fmt.Errorf("spop dirty: %w", err)
|
|
||||||
}
|
|
||||||
if len(ids) < dirtyDrainCap {
|
|
||||||
return ids, false, nil
|
|
||||||
}
|
|
||||||
remaining, err := c.client.SCard(ctx, key).Result()
|
|
||||||
if err != nil {
|
|
||||||
// We have the batch; not knowing the remainder just means the next
|
|
||||||
// tick picks it up on schedule.
|
|
||||||
return ids, false, nil
|
|
||||||
}
|
|
||||||
return ids, remaining > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSearchIndexTuplesFor reads the search tuples for a specific set of records
|
|
||||||
// — the incremental counterpart to GetSearchIndexTuples.
|
|
||||||
//
|
|
||||||
// Reads go through the per-record field index (GetMetadataFlat), so this is two
|
|
||||||
// round-trips per record rather than a keyspace scan. That trade is what makes
|
|
||||||
// it O(change): it beats the full pass whenever the changed set is a small
|
|
||||||
// fraction of the corpus, and the caller bounds the batch (dirtyDrainCap).
|
|
||||||
//
|
|
||||||
// A record present in the dirty set but absent from storage was deleted; it is
|
|
||||||
// reported in `gone` so the caller can evict it from the index rather than
|
|
||||||
// leaving a tombstone that still answers searches.
|
|
||||||
func (c *Client) GetSearchIndexTuplesFor(hashIDs []string) (tuples []SearchTuple, gone []string, err error) {
|
|
||||||
if len(hashIDs) == 0 {
|
|
||||||
return nil, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
excluded := indexExcludePrefixes()
|
|
||||||
tuples = make([]SearchTuple, 0, len(hashIDs))
|
|
||||||
|
|
||||||
for _, hashID := range hashIDs {
|
|
||||||
fields, ferr := c.GetMetadataFlat(hashID)
|
|
||||||
if ferr != nil {
|
|
||||||
// Re-mark so the record is retried rather than silently missing
|
|
||||||
// from the index until the next full rebuild.
|
|
||||||
c.MarkDirty(hashID)
|
|
||||||
log.Printf("[STORAGE] incremental index read failed for %s: %v", hashID, ferr)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(fields) == 0 {
|
|
||||||
gone = append(gone, hashID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for field := range fields {
|
|
||||||
if hasAnyPrefix(field, excluded) {
|
|
||||||
delete(fields, field)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tuples = append(tuples, SearchTuple{
|
|
||||||
HashID: hashID,
|
|
||||||
Haystack: buildHaystack(fields),
|
|
||||||
Fields: fields,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return tuples, gone, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildHaystack folds a record's searchable text fields into one lowercased
|
|
||||||
// blob. Shared by the full and incremental index reads so the two can never
|
|
||||||
// disagree about what is searchable — a drift there would make a record's
|
|
||||||
// matchability depend on which path happened to index it.
|
|
||||||
//
|
|
||||||
// Beyond the flat fields it folds in every `titles/<lang3>/<name>` member's
|
|
||||||
// name (METADATA_KEYS.md §3): the name lives in the key and the value is the
|
|
||||||
// sentinel "true", so a search for an alternative title (`3 percent`) finds the
|
|
||||||
// record whose `title` is `3%`.
|
|
||||||
func buildHaystack(fields map[string]string) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, f := range searchIndexFields {
|
|
||||||
if v, ok := fields[f]; ok && v != "" {
|
|
||||||
sb.WriteString(strings.ToLower(v))
|
|
||||||
sb.WriteByte('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, name := range titleMemberNames(fields) {
|
|
||||||
sb.WriteString(strings.ToLower(name))
|
|
||||||
sb.WriteByte('\n')
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// titleMemberNames returns the names of a record's `titles/<lang3>/<name>`
|
|
||||||
// key-set members, with the `∕` (U+2215) a writer substitutes for `/` mapped
|
|
||||||
// back. Sorted: the fields map has no order, and the full and incremental
|
|
||||||
// builds must produce byte-identical haystacks.
|
|
||||||
func titleMemberNames(fields map[string]string) []string {
|
|
||||||
var names []string
|
|
||||||
for k, v := range fields {
|
|
||||||
rest, ok := strings.CutPrefix(k, "titles/")
|
|
||||||
if !ok || v == "" || v == "false" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, name, ok := strings.Cut(rest, "/")
|
|
||||||
if !ok || name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
names = append(names, strings.ReplaceAll(name, "\u2215", "/"))
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sort"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tests for incremental search-index maintenance (search_dirty.go).
|
|
||||||
//
|
|
||||||
// The dirty set is derived state on the hot write path, which makes its failure
|
|
||||||
// mode the dangerous kind: a MISSING mark doesn't error, it just leaves the
|
|
||||||
// search index quietly serving a stale copy of that record until someone
|
|
||||||
// triggers a full rebuild. So the property worth pinning is coverage — every
|
|
||||||
// path that changes what a search would match must mark — and that is what the
|
|
||||||
// bulk of these tests assert, one write path at a time.
|
|
||||||
//
|
|
||||||
// The second property is that a delete is a mark too, not an unmark: the
|
|
||||||
// refresh has to LEARN the record is gone in order to evict it. Marking on
|
|
||||||
// delete is counter-intuitive enough to be worth a test of its own.
|
|
||||||
|
|
||||||
func dirtyMembers(t *testing.T, c *Client) []string {
|
|
||||||
t.Helper()
|
|
||||||
got, err := c.client.SMembers(context.Background(), c.buildDirtyKey()).Result()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("smembers dirty: %v", err)
|
|
||||||
}
|
|
||||||
sort.Strings(got)
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearDirty(t *testing.T, c *Client) {
|
|
||||||
t.Helper()
|
|
||||||
if err := c.client.Del(context.Background(), c.buildDirtyKey()).Err(); err != nil {
|
|
||||||
t.Fatalf("del dirty: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every write path must mark. A path that doesn't leaves the index serving a
|
|
||||||
// stale record with no error anywhere.
|
|
||||||
func TestDirty_EveryWritePathMarks(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
id string
|
|
||||||
write func(id string) error
|
|
||||||
}{
|
|
||||||
{"SetMetadataFlat", "r-set", func(id string) error {
|
|
||||||
return c.SetMetadataFlat(id, map[string]string{"title": "A"})
|
|
||||||
}},
|
|
||||||
{"SetProperty", "r-prop", func(id string) error {
|
|
||||||
return c.SetProperty(id, "title", "B")
|
|
||||||
}},
|
|
||||||
{"MergeMetadataFlat", "r-merge", func(id string) error {
|
|
||||||
_, err := c.MergeMetadataFlat(id, map[string]string{"title": "C"})
|
|
||||||
return err
|
|
||||||
}},
|
|
||||||
{"AddToSet", "r-add", func(id string) error {
|
|
||||||
_, err := c.AddToSet(id, "tags", "x")
|
|
||||||
return err
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
clearDirty(t, c)
|
|
||||||
if err := tc.write(tc.id); err != nil {
|
|
||||||
t.Fatalf("%s: %v", tc.name, err)
|
|
||||||
}
|
|
||||||
got := dirtyMembers(t, c)
|
|
||||||
if len(got) != 1 || got[0] != tc.id {
|
|
||||||
t.Fatalf("%s must mark %q dirty, got %v", tc.name, tc.id, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveFromSet's non-empty branch changes a field's VALUE without changing the
|
|
||||||
// field-name index, so it is the one path that a mark riding on field-index
|
|
||||||
// maintenance would have missed. Pinned explicitly for that reason.
|
|
||||||
func TestDirty_RemoveFromSetMarksEvenWhenTheFieldSurvives(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
const id = "r-rem"
|
|
||||||
for _, v := range []string{"x", "y"} {
|
|
||||||
if _, err := c.AddToSet(id, "tags", v); err != nil {
|
|
||||||
t.Fatalf("AddToSet: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clearDirty(t, c)
|
|
||||||
|
|
||||||
if _, err := c.RemoveFromSet(id, "tags", "x"); err != nil {
|
|
||||||
t.Fatalf("RemoveFromSet: %v", err)
|
|
||||||
}
|
|
||||||
// `tags` still exists (y remains) — no field-index change, but the
|
|
||||||
// searchable value changed.
|
|
||||||
if got := dirtyMembers(t, c); len(got) != 1 || got[0] != id {
|
|
||||||
t.Fatalf("a value-only change must still mark dirty, got %v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// A delete MARKS rather than unmarks: the refresh must read the record, find it
|
|
||||||
// gone, and evict it. Unmarking would leave a deleted record answering searches
|
|
||||||
// until the next full rebuild.
|
|
||||||
func TestDirty_DeletesMarkSoTheRefreshCanEvict(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
const id = "r-del"
|
|
||||||
if err := c.SetMetadataFlat(id, map[string]string{"title": "Doomed"}); err != nil {
|
|
||||||
t.Fatalf("SetMetadataFlat: %v", err)
|
|
||||||
}
|
|
||||||
clearDirty(t, c)
|
|
||||||
|
|
||||||
if _, err := c.DeleteMetadata(id); err != nil {
|
|
||||||
t.Fatalf("DeleteMetadata: %v", err)
|
|
||||||
}
|
|
||||||
if got := dirtyMembers(t, c); len(got) != 1 || got[0] != id {
|
|
||||||
t.Fatalf("a delete must mark dirty so the index evicts it, got %v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// …and the incremental read reports it as gone rather than as an error
|
|
||||||
// or an empty-but-present tuple.
|
|
||||||
tuples, gone, err := c.GetSearchIndexTuplesFor([]string{id})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSearchIndexTuplesFor: %v", err)
|
|
||||||
}
|
|
||||||
if len(tuples) != 0 {
|
|
||||||
t.Fatalf("a deleted record must produce no tuple, got %d", len(tuples))
|
|
||||||
}
|
|
||||||
if len(gone) != 1 || gone[0] != id {
|
|
||||||
t.Fatalf("a deleted record must be reported gone, got %v", gone)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// The drain is destructive so concurrent refreshes stay disjoint and a record
|
|
||||||
// isn't re-read every tick forever. The caller owns what it drained.
|
|
||||||
func TestDirty_DrainIsDestructive(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
clearDirty(t, c)
|
|
||||||
c.MarkDirty("a", "b", "c")
|
|
||||||
|
|
||||||
ids, more, err := c.DrainDirty()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("DrainDirty: %v", err)
|
|
||||||
}
|
|
||||||
sort.Strings(ids)
|
|
||||||
if len(ids) != 3 {
|
|
||||||
t.Fatalf("expected 3 drained ids, got %v", ids)
|
|
||||||
}
|
|
||||||
if more {
|
|
||||||
t.Fatal("a fully-drained set must not report more")
|
|
||||||
}
|
|
||||||
if got := dirtyMembers(t, c); len(got) != 0 {
|
|
||||||
t.Fatalf("drain must empty the set, got %v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A second drain is empty, not an error — the steady state when nothing
|
|
||||||
// has been written since the last tick.
|
|
||||||
ids, _, err = c.DrainDirty()
|
|
||||||
if err != nil || len(ids) != 0 {
|
|
||||||
t.Fatalf("draining an empty set must be a clean no-op, got %v / %v", ids, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// The incremental read must produce tuples indistinguishable from the full
|
|
||||||
// rebuild's — same haystack, same fields, same exclusions. A drift here would
|
|
||||||
// make a record's matchability depend on which path happened to index it, which
|
|
||||||
// is invisible until someone searches for the one term that differs.
|
|
||||||
func TestDirty_IncrementalTuplesMatchTheFullRebuild(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
const id = "r-parity"
|
|
||||||
if err := c.SetMetadataFlat(id, map[string]string{
|
|
||||||
"title": "Parity Release",
|
|
||||||
"fileName": "parity.s01e01.mkv",
|
|
||||||
"fileType": "video",
|
|
||||||
"titles/eng/3 Percent": "true",
|
|
||||||
"titles/fra/3 Pourcent": "true",
|
|
||||||
"categories/XXX": "true",
|
|
||||||
"categories/newznab/5070": "true",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("SetMetadataFlat: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
full := tuplesByHashID(t, c)[id]
|
|
||||||
incr, gone, err := c.GetSearchIndexTuplesFor([]string{id})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSearchIndexTuplesFor: %v", err)
|
|
||||||
}
|
|
||||||
if len(gone) != 0 || len(incr) != 1 {
|
|
||||||
t.Fatalf("expected exactly one live tuple, got %d live / %d gone", len(incr), len(gone))
|
|
||||||
}
|
|
||||||
|
|
||||||
if incr[0].Haystack != full.Haystack {
|
|
||||||
t.Errorf("haystack drift:\n incremental %q\n full %q", incr[0].Haystack, full.Haystack)
|
|
||||||
}
|
|
||||||
if len(incr[0].Fields) != len(full.Fields) {
|
|
||||||
t.Errorf("field-count drift: incremental %d, full %d", len(incr[0].Fields), len(full.Fields))
|
|
||||||
}
|
|
||||||
for k, v := range full.Fields {
|
|
||||||
if incr[0].Fields[k] != v {
|
|
||||||
t.Errorf("field %q drift: incremental %q, full %q", k, incr[0].Fields[k], v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The index projection must apply on both paths.
|
|
||||||
if _, ok := incr[0].Fields["categories/newznab/5070"]; ok {
|
|
||||||
t.Error("the incremental path must honour the index exclusion list too")
|
|
||||||
}
|
|
||||||
if _, ok := incr[0].Fields["categories/XXX"]; !ok {
|
|
||||||
t.Error("the incremental path must keep the adult-filter category")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// A `titles/<lang3>/<name>` member contributes its NAME (the key), never its
|
|
||||||
// "true" value, and the `∕` a writer substituted for `/` reads back as `/`.
|
|
||||||
func TestBuildHaystack_FoldsTitleMemberNames(t *testing.T) {
|
|
||||||
got := buildHaystack(map[string]string{
|
|
||||||
"title": "3%",
|
|
||||||
"titles/fra/3 Pourcent": "true",
|
|
||||||
"titles/eng/3 Percent": "true",
|
|
||||||
"titles/und/Fate\u2215Zero": "true",
|
|
||||||
"titles/eng": "Retired Scalar",
|
|
||||||
"titles/eng/": "true",
|
|
||||||
})
|
|
||||||
want := "3%\n3 percent\n3 pourcent\nfate/zero\n"
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("haystack = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tests for the search-index field projection (GetSearchIndexTuples +
|
|
||||||
// indexExcludePrefixes).
|
|
||||||
//
|
|
||||||
// The projection narrows the in-memory index only — Redis keeps every field and
|
|
||||||
// every per-record read still returns it. What has to be pinned is the boundary
|
|
||||||
// between the two, because getting it wrong in either direction is silent:
|
|
||||||
// exclude too little and the index keeps paying for fields nothing reads;
|
|
||||||
// exclude too much and a record served *from the index* comes back missing a
|
|
||||||
// field a downstream consumer depends on.
|
|
||||||
//
|
|
||||||
// The specific hazard the default guards is adult-content filtering:
|
|
||||||
// `categories/XXX` is the server-side enforcement point (meta-search's
|
|
||||||
// `query_eval::fields_are_adult`, meta-gateway's `cards::fields_are_adult`),
|
|
||||||
// and `exclude_adult` defaults to ON. Excluding the whole `categories/` family
|
|
||||||
// would make every index-served record read as non-adult and the filter would
|
|
||||||
// fail OPEN — so the default must keep `categories/XXX` while dropping the
|
|
||||||
// reader-less `categories/newznab/` mirror.
|
|
||||||
|
|
||||||
func tuplesByHashID(t *testing.T, c *Client) map[string]SearchTuple {
|
|
||||||
t.Helper()
|
|
||||||
got, err := c.GetSearchIndexTuples()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSearchIndexTuples: %v", err)
|
|
||||||
}
|
|
||||||
out := make(map[string]SearchTuple, len(got))
|
|
||||||
for _, tu := range got {
|
|
||||||
out[tu.HashID] = tu
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearchIndex_ExcludesNewznabCategoriesButKeepsTheRest(t *testing.T) {
|
|
||||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
|
||||||
const id = "root-1"
|
|
||||||
if err := c.SetMetadataFlat(id, map[string]string{
|
|
||||||
"title": "Some Release",
|
|
||||||
"fileType": "video",
|
|
||||||
"categories/TV/Anime": "true",
|
|
||||||
"categories/XXX": "true",
|
|
||||||
"categories/newznab/5070": "true",
|
|
||||||
"categories/newznab/2040": "true",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("SetMetadataFlat: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fields := tuplesByHashID(t, c)[id].Fields
|
|
||||||
if fields == nil {
|
|
||||||
t.Fatal("record missing from the search index")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, excluded := range []string{"categories/newznab/5070", "categories/newznab/2040"} {
|
|
||||||
if _, ok := fields[excluded]; ok {
|
|
||||||
t.Errorf("%s must not be indexed — it is a reader-less mirror vocabulary", excluded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The load-bearing ones must survive. XXX especially: without it the
|
|
||||||
// adult filter fails open on every index-served record.
|
|
||||||
for _, kept := range []string{"categories/XXX", "categories/TV/Anime", "title", "fileType"} {
|
|
||||||
if _, ok := fields[kept]; !ok {
|
|
||||||
t.Errorf("%s must stay in the index", kept)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The store itself is untouched — the projection is index-only.
|
|
||||||
stored, err := c.GetMetadataFlat(id)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetMetadataFlat: %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := stored["categories/newznab/5070"]; !ok {
|
|
||||||
t.Error("an index-excluded field must still be stored and readable per-record")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIndexExcludePrefixes_EnvOverride(t *testing.T) {
|
|
||||||
if got := indexExcludePrefixes(); len(got) != 1 || got[0] != "categories/newznab/" {
|
|
||||||
t.Fatalf("unset env must yield the default, got %v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("META_CORE_INDEX_EXCLUDE_PREFIXES", "foo/, bar/ ,")
|
|
||||||
got := indexExcludePrefixes()
|
|
||||||
if len(got) != 2 || got[0] != "foo/" || got[1] != "bar/" {
|
|
||||||
t.Fatalf("override must trim and drop blanks, got %v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// An explicitly empty value is the escape hatch: index everything.
|
|
||||||
t.Setenv("META_CORE_INDEX_EXCLUDE_PREFIXES", "")
|
|
||||||
if got := indexExcludePrefixes(); len(got) != 0 {
|
|
||||||
t.Fatalf("an empty override must disable exclusion, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHasAnyPrefix(t *testing.T) {
|
|
||||||
prefixes := []string{"categories/newznab/", "tmp/"}
|
|
||||||
cases := map[string]bool{
|
|
||||||
"categories/newznab/5070": true,
|
|
||||||
"tmp/scratch": true,
|
|
||||||
"categories/XXX": false,
|
|
||||||
"categories/newznab": false, // the family root is not the mirror
|
|
||||||
"title": false,
|
|
||||||
}
|
|
||||||
for field, want := range cases {
|
|
||||||
if got := hasAnyPrefix(field, prefixes); got != want {
|
|
||||||
t.Errorf("hasAnyPrefix(%q) = %v, want %v", field, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasAnyPrefix("anything", nil) {
|
|
||||||
t.Error("an empty prefix list must exclude nothing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/kv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests pin the one property storage.Search must have: its SQL
|
||||||
|
// candidates are a SUPERSET of the records the exact matcher accepts. A
|
||||||
|
// candidate miss is a silently dropped search result, so each case below is a
|
||||||
|
// shape where SQLite and Go disagree about text (case folding, csv values,
|
||||||
|
// FTS syntax, trigram length) and the candidate query has to cover for it.
|
||||||
|
//
|
||||||
|
// The matcher here mirrors api.recordMatchesFilter closely enough to exercise
|
||||||
|
// every candidate path; the API tests own the matcher itself.
|
||||||
|
|
||||||
|
// matchFilter is a faithful copy of api.recordMatchesFilter's semantics.
|
||||||
|
func matchFilter(fields map[string]string, prop string, exact bool, values ...string) bool {
|
||||||
|
for _, v := range values {
|
||||||
|
if fields[prop+"/"+v] != "" && fields[prop+"/"+v] != "false" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefix := strings.ToLower(prop) + "/"
|
||||||
|
for k, val := range fields {
|
||||||
|
if len(k) > len(prefix) && strings.EqualFold(k[:len(prefix)], prefix) {
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.EqualFold(k[len(prefix):], v) && val != "" && val != "false" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fv, ok := fields[prop]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, piece := range strings.Split(fv, ",") {
|
||||||
|
piece = strings.TrimSpace(piece)
|
||||||
|
for _, v := range values {
|
||||||
|
if exact && strings.EqualFold(piece, v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !exact && strings.Contains(strings.ToLower(piece), strings.ToLower(v)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func search(t *testing.T, c *Client, query string, filters []SearchFilter, match func(string, map[string]string) bool) []string {
|
||||||
|
t.Helper()
|
||||||
|
hits, err := c.Search(query, filters, 100, match)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(hits))
|
||||||
|
for _, h := range hits {
|
||||||
|
ids = append(ids, h.HashID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func textMatch(query string) func(string, map[string]string) bool {
|
||||||
|
tokens := strings.Fields(strings.ToLower(query))
|
||||||
|
return func(id string, fields map[string]string) bool {
|
||||||
|
hay := Haystack(fields)
|
||||||
|
for _, tok := range tokens {
|
||||||
|
if !strings.Contains(hay, tok) {
|
||||||
|
return strings.Contains(strings.ToLower(id), strings.ToLower(query))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterMatch(prop string, exact bool, values ...string) func(string, map[string]string) bool {
|
||||||
|
return func(_ string, fields map[string]string) bool { return matchFilter(fields, prop, exact, values...) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalIDs(t *testing.T, got []string, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
sort.Strings(want)
|
||||||
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchFilterCandidates(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
seed(t, c, map[string]map[string]string{
|
||||||
|
"R1": {"title": "One", "genres/Action": "true", "videoType": "tv, anime", "fileType": "video"},
|
||||||
|
"R2": {"title": "Two", "genres/Comedy": "true", "videoType": "movie", "fileType": "VIDEO"},
|
||||||
|
"R3": {"title": "Three", "Genres/Drama": "true", "fileType": "audio", "tmdbid": "21405"},
|
||||||
|
"R4": {"title": "Four", "genres/Ärger": "true", "tmdbid": "1405", "genres/Action": "false"},
|
||||||
|
})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
prop string
|
||||||
|
exact bool
|
||||||
|
values []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"key-set member, query lowercased", "genres", false, []string{"action"}, []string{"R1"}},
|
||||||
|
{"key-set member under a capitalised property", "genres", false, []string{"drama"}, []string{"R3"}},
|
||||||
|
{"key-set member, non-ASCII value", "genres", false, []string{"ärger"}, []string{"R4"}},
|
||||||
|
{"csv value, exact", "videoType", true, []string{"anime"}, []string{"R1"}},
|
||||||
|
{"flat value, case differs", "fileType", true, []string{"video"}, []string{"R1", "R2"}},
|
||||||
|
{"exact rejects a superstring", "tmdbid", true, []string{"1405"}, []string{"R4"}},
|
||||||
|
{"contains admits a superstring", "tmdbid", false, []string{"1405"}, []string{"R3", "R4"}},
|
||||||
|
{"OR across values", "genres", false, []string{"action", "comedy"}, []string{"R1", "R2"}},
|
||||||
|
{"falsey member is not membership", "genres", false, []string{"action"}, []string{"R1"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := search(t, c, "", []SearchFilter{{Property: tc.prop, Values: tc.values}}, filterMatch(tc.prop, tc.exact, tc.values...))
|
||||||
|
equalIDs(t, got, tc.want...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchFiltersAreAndedAcrossEntries(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
seed(t, c, map[string]map[string]string{
|
||||||
|
"R1": {"genres/Action": "true", "fileType": "video"},
|
||||||
|
"R2": {"genres/Action": "true", "fileType": "audio"},
|
||||||
|
})
|
||||||
|
match := func(_ string, f map[string]string) bool {
|
||||||
|
return matchFilter(f, "genres", false, "action") && matchFilter(f, "fileType", true, "video")
|
||||||
|
}
|
||||||
|
got := search(t, c, "", []SearchFilter{{Property: "genres", Values: []string{"action"}}, {Property: "fileType", Values: []string{"video"}}}, match)
|
||||||
|
equalIDs(t, got, "R1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchFreeTextCandidates(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
seed(t, c, map[string]map[string]string{
|
||||||
|
"01KAAA": {"title": "Naruto Shippuden", "fileName": "naruto.s01e02.mkv"},
|
||||||
|
"01KBBB": {"title": "Dune: Part Two"},
|
||||||
|
"01KCCC": {"title": "進撃の巨人"},
|
||||||
|
"01KDDD": {"title": "3%", "titles/eng/3 percent": "true"},
|
||||||
|
"01KEEE": {"title": "AND -x a:b * \"quoted\""},
|
||||||
|
"01KFFF": {"title": "Unrelated"},
|
||||||
|
})
|
||||||
|
cases := []struct {
|
||||||
|
query string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"naruto", []string{"01KAAA"}},
|
||||||
|
{"NARUTO s01e02", []string{"01KAAA"}},
|
||||||
|
{"dune:", []string{"01KBBB"}},
|
||||||
|
{"巨人", []string{"01KCCC"}}, // 2 runes: below trigram length
|
||||||
|
{"3 percent", []string{"01KDDD"}}, // AKA via titles/ key-set
|
||||||
|
{"3%", []string{"01KDDD"}}, // LIKE metacharacter in a short token
|
||||||
|
{"AND", []string{"01KEEE"}}, // FTS operator as a literal
|
||||||
|
{"-x", []string{"01KEEE"}},
|
||||||
|
{"a:b", []string{"01KEEE"}},
|
||||||
|
{"*", []string{"01KEEE"}},
|
||||||
|
{`"quoted"`, []string{"01KEEE"}},
|
||||||
|
{"01kbb", []string{"01KBBB"}}, // whole query is a hashID substring
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.query, func(t *testing.T) {
|
||||||
|
got := search(t, c, tc.query, nil, textMatch(tc.query))
|
||||||
|
equalIDs(t, got, tc.want...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rid token and a title token must not match together: the rid is in the
|
||||||
|
// FTS row only as a candidate path for the "whole query is a hashID substring"
|
||||||
|
// rule, and verification checks the two separately.
|
||||||
|
func TestSearchRidAndTitleTokensDoNotCombine(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
seed(t, c, map[string]map[string]string{"01KZZZ": {"title": "Naruto"}})
|
||||||
|
if got := search(t, c, "01kzz naruto", nil, textMatch("01kzz naruto")); len(got) != 0 {
|
||||||
|
t.Fatalf("rid + title token matched: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchFollowsWritesAndDeletes(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
seed(t, c, map[string]map[string]string{"R1": {"title": "Before"}})
|
||||||
|
setMerged(t, c, "R1", map[string]string{"title": "After"})
|
||||||
|
equalIDs(t, search(t, c, "after", nil, textMatch("after")), "R1")
|
||||||
|
equalIDs(t, search(t, c, "before", nil, textMatch("before")))
|
||||||
|
deleteMerged(t, c, "R1")
|
||||||
|
equalIDs(t, search(t, c, "after", nil, textMatch("after")))
|
||||||
|
equalIDs(t, search(t, c, "", nil, func(string, map[string]string) bool { return true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchPagesPastRejectedCandidates(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
records := map[string]map[string]string{}
|
||||||
|
for i := 0; i < 450; i++ {
|
||||||
|
id := "R" + string(rune('A'+i/26%26)) + string(rune('A'+i%26)) + strings.Repeat("0", 3)
|
||||||
|
records[id+string(rune('a'+i/676))] = map[string]string{"fileType": "video"}
|
||||||
|
}
|
||||||
|
records["RZZZZ"] = map[string]string{"fileType": "video", "keep": "yes"}
|
||||||
|
seed(t, c, records)
|
||||||
|
got := search(t, c, "", []SearchFilter{{Property: "fileType", Values: []string{"video"}}},
|
||||||
|
func(_ string, f map[string]string) bool { return f["keep"] == "yes" })
|
||||||
|
equalIDs(t, got, "RZZZZ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filter candidates only scale if SQLite actually uses the partial
|
||||||
|
// expression index — which it does only when the query repeats the index's
|
||||||
|
// expression and WHERE clause as literals.
|
||||||
|
func TestSearchFilterUsesFieldIndex(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
seed(t, c, map[string]map[string]string{"R1": {"fileType": "video"}})
|
||||||
|
q, args := candidateSQL("", []SearchFilter{{Property: "fileType", Values: []string{"video"}}})
|
||||||
|
rows, err := c.Store().DB().QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+q, args...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var plan []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id, parent, notused int
|
||||||
|
var detail string
|
||||||
|
if err := rows.Scan(&id, &parent, ¬used, &detail); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plan = append(plan, detail)
|
||||||
|
}
|
||||||
|
joined := strings.Join(plan, " | ")
|
||||||
|
if !strings.Contains(joined, "COVERING INDEX kv_field_v") || strings.Contains(joined, "SCAN kv") {
|
||||||
|
t.Fatalf("filter candidates do not use kv_field_v: %s", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCIDRootedRecordsParseAtTheFirstSlash(t *testing.T) {
|
||||||
|
if h, f, ok := splitRecordKey("file:bafkreiabc/titles/eng/x"); !ok || h != "bafkreiabc" || f != "titles/eng/x" {
|
||||||
|
t.Fatalf("got %q %q %v", h, f, ok)
|
||||||
|
}
|
||||||
|
c := newTestClient(t)
|
||||||
|
seed(t, c, map[string]map[string]string{"bafkreiabcdefghijklmnop": {"fileType": "video"}})
|
||||||
|
got := search(t, c, "", []SearchFilter{{Property: "fileType", Values: []string{"video"}}}, filterMatch("fileType", true, "video"))
|
||||||
|
equalIDs(t, got, "bafkreiabcdefghijklmnop")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRebuildSearchIfEmptyRestoresFreeText(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
seed(t, c, map[string]map[string]string{"R1": {"title": "Naruto"}, "R2": {"title": "Dune"}})
|
||||||
|
if err := c.update(func(tx *kv.Tx) error {
|
||||||
|
if _, err := tx.Exec(`DELETE FROM fts`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := tx.DeletePrefix(ftsNS, 0)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
equalIDs(t, search(t, c, "naruto", nil, textMatch("naruto")))
|
||||||
|
n, err := c.RebuildSearchIfEmpty()
|
||||||
|
if err != nil || n != 2 {
|
||||||
|
t.Fatalf("rebuilt %d (err %v), want 2", n, err)
|
||||||
|
}
|
||||||
|
equalIDs(t, search(t, c, "naruto", nil, textMatch("naruto")), "R1")
|
||||||
|
if n, _ := c.RebuildSearchIfEmpty(); n != 0 {
|
||||||
|
t.Fatalf("second rebuild ran again (%d)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text plus a broad filter: the selective part drives and the filter becomes a
|
||||||
|
// per-candidate probe over the record's own key range — never a full
|
||||||
|
// materialisation of the filter's result set.
|
||||||
|
func TestSearchCombinedPlanProbesByPrimaryKey(t *testing.T) {
|
||||||
|
c := newTestClient(t)
|
||||||
|
recs := map[string]map[string]string{"R0000": {"title": "Naruto", "fileType": "video"}}
|
||||||
|
for i := 1; i < 300; i++ {
|
||||||
|
recs[fmt.Sprintf("R%04d", i)] = map[string]string{"title": "Other", "fileType": "video"}
|
||||||
|
}
|
||||||
|
seed(t, c, recs)
|
||||||
|
filters := []SearchFilter{{Property: "fileType", Values: []string{"video"}}}
|
||||||
|
plan, err := c.planCandidates(context.Background(), c.Store(), "naruto", filters)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if plan.walk || len(plan.ordered) != 1 || len(plan.probes) != 1 {
|
||||||
|
t.Fatalf("the rare text should drive (1 rid) with the filter as a probe, got %+v", plan)
|
||||||
|
}
|
||||||
|
probes := plan.probes
|
||||||
|
q, pargs := probeSQL(probes)
|
||||||
|
args := append([]any{`["R0000"]`}, pargs...)
|
||||||
|
rows, err := c.Store().DB().QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+q, args...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var steps []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id, parent, notused int
|
||||||
|
var detail string
|
||||||
|
if err := rows.Scan(&id, &parent, ¬used, &detail); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
steps = append(steps, detail)
|
||||||
|
}
|
||||||
|
joined := strings.Join(steps, " | ")
|
||||||
|
if !strings.Contains(joined, "PRIMARY KEY") || strings.Contains(joined, "SCAN kv") {
|
||||||
|
t.Fatalf("filter probe is not a primary-key range read: %s", joined)
|
||||||
|
}
|
||||||
|
equalIDs(t, search(t, c, "naruto", filters, func(id string, f map[string]string) bool {
|
||||||
|
return textMatch("naruto")(id, f) && matchFilter(f, "fileType", false, "video")
|
||||||
|
}), "R0000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// When every part is broad (above estimateCap), the smallest is still
|
||||||
|
// collected and drives — no walk over the roots — and the result is exact.
|
||||||
|
func TestSearchBroadPartsStillDriveFromTheSmallest(t *testing.T) {
|
||||||
|
defer func(old int) { estimateCapForTest(old) }(estimateCapForTest(3))
|
||||||
|
c := newTestClient(t)
|
||||||
|
recs := map[string]map[string]string{}
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
ft := "video"
|
||||||
|
if i%2 == 1 {
|
||||||
|
ft = "audio"
|
||||||
|
}
|
||||||
|
recs[fmt.Sprintf("R%02d", i)] = map[string]string{"title": "Common words", "fileType": ft}
|
||||||
|
}
|
||||||
|
seed(t, c, recs)
|
||||||
|
filters := []SearchFilter{{Property: "fileType", Values: []string{"audio"}}}
|
||||||
|
plan, err := c.planCandidates(context.Background(), c.Store(), "common", filters)
|
||||||
|
if err != nil || plan.walk || len(plan.ordered) != 20 || len(plan.probes) != 1 {
|
||||||
|
t.Fatalf("want the text (20 rids) driving with one probe, got walk=%v ordered=%d probes=%d err %v", plan.walk, len(plan.ordered), len(plan.probes), err)
|
||||||
|
}
|
||||||
|
got := search(t, c, "common", filters, func(id string, f map[string]string) bool {
|
||||||
|
return textMatch("common")(id, f) && matchFilter(f, "fileType", false, "audio")
|
||||||
|
})
|
||||||
|
if len(got) != 10 || got[0] != "R01" {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-35
@@ -1,52 +1,47 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CountRoots returns the number of metadata roots in the file index.
|
// CountRoots returns the number of metadata roots: a counter the write path
|
||||||
//
|
// keeps exact (see onChange), so it costs one read — search reports it as
|
||||||
// SCARD, not SMEMBERS: GetAllHashIDs ships every member over the wire (~3MB
|
// `total` on every query.
|
||||||
// and rising on an 88k-record box) just so the caller can take len(). A
|
|
||||||
// dashboard tile that polls needs the number, never the list.
|
|
||||||
func (c *Client) CountRoots() (int64, error) {
|
func (c *Client) CountRoots() (int64, error) {
|
||||||
c.mu.RLock()
|
s, err := c.kv()
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return 0, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
n, err := c.client.SCard(ctx, c.buildIndexKey()).Result()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("scard failed: %w", err)
|
return 0, err
|
||||||
}
|
}
|
||||||
return n, nil
|
ctx, cancel := opCtx()
|
||||||
|
defer cancel()
|
||||||
|
n, _, err := s.Counter(ctx, rootsCountKey)
|
||||||
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountKeys returns DBSIZE — every key in the Redis database, not just the
|
// keyCountTTL bounds how stale CountKeys may be. Counting every row is a full
|
||||||
// ones meta-core owns. It is the denominator for "how much of Redis is
|
// table scan (~a second at millions of keys), and the only reader is a polled
|
||||||
// records": ~3.1M keys against ~89k roots means the flat per-field keys
|
// dashboard tile — it needs a number, not a fresh one every few seconds.
|
||||||
// (file:{root}/{field}) dominate, which is expected.
|
const keyCountTTL = 60 * time.Second
|
||||||
|
|
||||||
|
// CountKeys returns the number of rows in the database — every key, not just
|
||||||
|
// records. It is the denominator for "how much of the store is records": the
|
||||||
|
// flat per-field keys (file:{root}/{field}) dominate, which is expected.
|
||||||
func (c *Client) CountKeys() (int64, error) {
|
func (c *Client) CountKeys() (int64, error) {
|
||||||
c.mu.RLock()
|
c.keysMu.Lock()
|
||||||
defer c.mu.RUnlock()
|
defer c.keysMu.Unlock()
|
||||||
|
if !c.keysAt.IsZero() && time.Since(c.keysAt) < keyCountTTL {
|
||||||
if c.client == nil {
|
return c.keysCount, nil
|
||||||
return 0, fmt.Errorf("not connected")
|
|
||||||
}
|
}
|
||||||
|
s, err := c.kv()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
n, err := c.client.DBSize(ctx).Result()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("dbsize failed: %w", err)
|
return 0, err
|
||||||
}
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
|
defer cancel()
|
||||||
|
n, err := s.CountAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
c.keysCount, c.keysAt = n, time.Now()
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,701 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
// The truth database: the only data meta-core cannot rebuild.
|
||||||
|
//
|
||||||
|
// principal(pid, uid) who signed (pid = local short form)
|
||||||
|
// claim(cid, key, pid) → value, ts, sig, seq every vote, signed (MMUR2)
|
||||||
|
// cid_group(cid) → rid which record a cid belongs to
|
||||||
|
// meta(name) → value schema version, last_seq
|
||||||
|
//
|
||||||
|
// Primary keys only — every other index lives in the cache, which is rebuilt
|
||||||
|
// from these four tables. A claim never mentions a rid: the rid is a local
|
||||||
|
// name for a group of cids, minted here (UUIDv7) and kept because consumers
|
||||||
|
// hold rids.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/metazla/meta-core/internal/claim"
|
||||||
|
"github.com/metazla/meta-core/internal/kv"
|
||||||
|
)
|
||||||
|
|
||||||
|
const truthSchema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS principal(
|
||||||
|
pid INTEGER PRIMARY KEY,
|
||||||
|
uid TEXT NOT NULL UNIQUE
|
||||||
|
) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS claim(
|
||||||
|
cid TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
pid INTEGER NOT NULL,
|
||||||
|
value ANY,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
sig BLOB NOT NULL,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (cid, key, pid)
|
||||||
|
) STRICT, WITHOUT ROWID;
|
||||||
|
CREATE TABLE IF NOT EXISTS cid_group(cid TEXT PRIMARY KEY, rid TEXT NOT NULL) STRICT, WITHOUT ROWID;
|
||||||
|
CREATE TABLE IF NOT EXISTS meta(name TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT, WITHOUT ROWID;
|
||||||
|
`
|
||||||
|
|
||||||
|
const lastSeqName = "last_seq"
|
||||||
|
|
||||||
|
// ClaimStatus is the per-claim outcome of POST /api/claims.
|
||||||
|
type ClaimStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusAccepted ClaimStatus = "accepted"
|
||||||
|
StatusUnchanged ClaimStatus = "unchanged"
|
||||||
|
StatusStale ClaimStatus = "stale"
|
||||||
|
StatusBadSignature ClaimStatus = "bad_signature"
|
||||||
|
StatusBadRequest ClaimStatus = "bad_request"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClaimResult is one entry of the POST /api/claims response.
|
||||||
|
type ClaimResult struct {
|
||||||
|
Status ClaimStatus `json:"status"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxClaimsPerBatch bounds one POST /api/claims.
|
||||||
|
const MaxClaimsPerBatch = 10_000
|
||||||
|
|
||||||
|
// change is one committed truth change, queued for the apply loop in commit
|
||||||
|
// order. key == "" means "membership only" (a cid joined a group).
|
||||||
|
type change struct {
|
||||||
|
seq int64
|
||||||
|
cid string
|
||||||
|
key string
|
||||||
|
pid int64
|
||||||
|
live bool // the claim now holds a value (false: tombstone)
|
||||||
|
regroup *[2]string // {loser, survivor}: two groups merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueArg(v claim.Value) any {
|
||||||
|
switch v.Kind {
|
||||||
|
case claim.KindText:
|
||||||
|
return v.Text
|
||||||
|
case claim.KindBytes:
|
||||||
|
if v.Bytes == nil {
|
||||||
|
return []byte{}
|
||||||
|
}
|
||||||
|
return v.Bytes
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanValue turns a claim.value column (TEXT / BLOB / NULL) back into a Value.
|
||||||
|
func scanValue(raw any) claim.Value {
|
||||||
|
switch x := raw.(type) {
|
||||||
|
case string:
|
||||||
|
return claim.Text(x)
|
||||||
|
case []byte:
|
||||||
|
return claim.Bytes(x)
|
||||||
|
}
|
||||||
|
return claim.Null()
|
||||||
|
}
|
||||||
|
|
||||||
|
type storedClaim struct {
|
||||||
|
ts int64
|
||||||
|
sig []byte
|
||||||
|
value claim.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func readStored(ctx context.Context, q interface {
|
||||||
|
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||||
|
}, cid, key, uid string) (storedClaim, bool, error) {
|
||||||
|
var sc storedClaim
|
||||||
|
var raw any
|
||||||
|
err := q.QueryRowContext(ctx,
|
||||||
|
`SELECT c.ts, c.sig, c.value FROM claim c JOIN principal p ON p.pid = c.pid
|
||||||
|
WHERE c.cid = ? AND c.key = ? AND p.uid = ?`, cid, key, uid).Scan(&sc.ts, &sc.sig, &raw)
|
||||||
|
sc.value = scanValue(raw)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return sc, false, nil
|
||||||
|
}
|
||||||
|
return sc, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// precheck classifies a claim against the committed state without the writer:
|
||||||
|
// skip-identical happens BEFORE verification (most writes are re-persists of
|
||||||
|
// the same signed claim), stale is refused, everything else is verified.
|
||||||
|
func (c *Client) precheck(ctx context.Context, cl *claim.Claim) (ClaimStatus, string) {
|
||||||
|
if cl.CID == "" || cl.Key == "" || cl.UID == "" {
|
||||||
|
return StatusBadRequest, "cid, uid and key are required"
|
||||||
|
}
|
||||||
|
if !claimKeyAllowed(cl.Key) {
|
||||||
|
return StatusBadRequest, "key is reserved or malformed"
|
||||||
|
}
|
||||||
|
if len(cl.Key) > 4096 || len(cl.CID) > 512 {
|
||||||
|
return StatusBadRequest, "cid or key too long"
|
||||||
|
}
|
||||||
|
if cl.TS == 0 || cl.TS > 1<<62 {
|
||||||
|
return StatusBadRequest, "ts out of range"
|
||||||
|
}
|
||||||
|
sc, ok, err := readStored(ctx, c.truth.DB(), cl.CID, cl.Key, cl.UID)
|
||||||
|
if err != nil {
|
||||||
|
return StatusBadRequest, err.Error()
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
if sc.ts == int64(cl.TS) && string(sc.sig) == string(cl.Sig) {
|
||||||
|
return StatusUnchanged, ""
|
||||||
|
}
|
||||||
|
// Same signer re-asserting the value it already holds (the gateway
|
||||||
|
// re-persists every search hit with a fresh ts): keep the stored row and
|
||||||
|
// its older ts, so oldest-ts tie-breaking stays anchored and truth takes
|
||||||
|
// no write. Nothing is stored, so the new signature needs no check.
|
||||||
|
if sc.value.Equal(cl.Value) {
|
||||||
|
return StatusUnchanged, ""
|
||||||
|
}
|
||||||
|
if sc.ts >= int64(cl.TS) {
|
||||||
|
return StatusStale, "a newer or equal ts is stored for this signer, cid and key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := cl.Verify(); err != nil {
|
||||||
|
return StatusBadSignature, err.Error()
|
||||||
|
}
|
||||||
|
return StatusAccepted, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteClaims is POST /api/claims: verify, store, regroup, and wait until the
|
||||||
|
// cache has applied the batch (read-your-writes for the caller).
|
||||||
|
func (c *Client) WriteClaims(ctx context.Context, claims []claim.Claim) ([]ClaimResult, error) {
|
||||||
|
truth, err := c.truthStore()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results := make([]ClaimResult, len(claims))
|
||||||
|
var todo []int
|
||||||
|
for i := range claims {
|
||||||
|
st, reason := c.precheck(ctx, &claims[i])
|
||||||
|
results[i] = ClaimResult{Status: st, Reason: reason}
|
||||||
|
if st == StatusAccepted {
|
||||||
|
todo = append(todo, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(todo) == 0 {
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
var maxSeq int64
|
||||||
|
err = truth.Update(ctx, func(tx *kv.Tx) error {
|
||||||
|
seq, err := readLastSeq(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var changes []change
|
||||||
|
pids := map[string]int64{}
|
||||||
|
for _, i := range todo {
|
||||||
|
cl := &claims[i]
|
||||||
|
// Re-check under the writer: a concurrent batch may have stored a
|
||||||
|
// newer vote since the precheck.
|
||||||
|
sc, ok, err := readStored(tx.Context(), txQuerier{tx}, cl.CID, cl.Key, cl.UID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ok && (sc.ts >= int64(cl.TS) || sc.value.Equal(cl.Value)) {
|
||||||
|
if sc.value.Equal(cl.Value) {
|
||||||
|
results[i] = ClaimResult{Status: StatusUnchanged}
|
||||||
|
} else {
|
||||||
|
results[i] = ClaimResult{Status: StatusStale, Reason: "a newer or equal ts is stored for this signer, cid and key"}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pid, ok := pids[cl.UID]
|
||||||
|
if !ok {
|
||||||
|
if pid, err = ensurePrincipal(tx, cl.UID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pids[cl.UID] = pid
|
||||||
|
}
|
||||||
|
seq++
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO claim(cid, key, pid, value, ts, sig, seq) VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(cid, key, pid) DO UPDATE SET value = excluded.value, ts = excluded.ts,
|
||||||
|
sig = excluded.sig, seq = excluded.seq`,
|
||||||
|
cl.CID, cl.Key, pid, valueArg(cl.Value), int64(cl.TS), cl.Sig, seq); err != nil {
|
||||||
|
return fmt.Errorf("store claim: %w", err)
|
||||||
|
}
|
||||||
|
if _, _, err := ensureGroup(tx, cl.CID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changes = append(changes, change{seq: seq, cid: cl.CID, key: cl.Key, pid: pid, live: !cl.Value.IsNull()})
|
||||||
|
if other := cidFromKeysetField(cl.Key); other != "" && cl.Value.Kind == claim.KindText && other != cl.CID {
|
||||||
|
more, err := linkGroups(tx, cl.CID, other, seq)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changes = append(changes, more...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := writeLastSeq(tx, seq); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
maxSeq = seq
|
||||||
|
tx.OnCommit(func() { c.applier.enqueue(changes) })
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if maxSeq > 0 {
|
||||||
|
if err := c.applier.wait(ctx, maxSeq); err != nil {
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// txQuerier adapts a kv.Tx to the QueryRowContext shape readStored takes.
|
||||||
|
type txQuerier struct{ tx *kv.Tx }
|
||||||
|
|
||||||
|
func (q txQuerier) QueryRowContext(_ context.Context, query string, args ...any) *sql.Row {
|
||||||
|
return q.tx.QueryRow(query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLastSeq(tx *kv.Tx) (int64, error) {
|
||||||
|
var v string
|
||||||
|
err := tx.QueryRow(`SELECT value FROM meta WHERE name = ?`, lastSeqName).Scan(&v)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return strconv.ParseInt(v, 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLastSeq(tx *kv.Tx, seq int64) error {
|
||||||
|
_, err := tx.Exec(`INSERT INTO meta(name, value) VALUES(?, ?)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET value = excluded.value`, lastSeqName, strconv.FormatInt(seq, 10))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensurePrincipal(tx *kv.Tx, uid string) (int64, error) {
|
||||||
|
var pid int64
|
||||||
|
err := tx.QueryRow(`SELECT pid FROM principal WHERE uid = ?`, uid).Scan(&pid)
|
||||||
|
if err == nil {
|
||||||
|
return pid, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := tx.QueryRow(`INSERT INTO principal(uid) VALUES(?) RETURNING pid`, uid).Scan(&pid); err != nil {
|
||||||
|
return 0, fmt.Errorf("new principal: %w", err)
|
||||||
|
}
|
||||||
|
return pid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureGroup returns cid's rid, minting a new group for an unknown cid.
|
||||||
|
func ensureGroup(tx *kv.Tx, cid string) (string, bool, error) {
|
||||||
|
var rid string
|
||||||
|
err := tx.QueryRow(`SELECT rid FROM cid_group WHERE cid = ?`, cid).Scan(&rid)
|
||||||
|
if err == nil {
|
||||||
|
return rid, false, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if rid, err = NewUUID(); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO cid_group(cid, rid) VALUES(?, ?)`, cid, rid); err != nil {
|
||||||
|
return "", false, fmt.Errorf("new group: %w", err)
|
||||||
|
}
|
||||||
|
return rid, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkGroups records that a and b are the same content. An unknown b joins a's
|
||||||
|
// group; two known groups merge, the older rid surviving (UUIDv7 sorts by
|
||||||
|
// time). A retraction of the link does not split the group (phase 1).
|
||||||
|
func linkGroups(tx *kv.Tx, a, b string, seq int64) ([]change, error) {
|
||||||
|
ra, _, err := ensureGroup(tx, a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rb string
|
||||||
|
err = tx.QueryRow(`SELECT rid FROM cid_group WHERE cid = ?`, b).Scan(&rb)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
if _, err := tx.Exec(`INSERT INTO cid_group(cid, rid) VALUES(?, ?)`, b, ra); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []change{{seq: seq, cid: b}}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ra == rb {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
survivor, loser := ra, rb
|
||||||
|
if rb < ra {
|
||||||
|
survivor, loser = rb, ra
|
||||||
|
}
|
||||||
|
// cid_group has no rid index (truth keeps primary keys only); a merge is
|
||||||
|
// rare, so the scan is acceptable.
|
||||||
|
if _, err := tx.Exec(`UPDATE cid_group SET rid = ? WHERE rid = ?`, survivor, loser); err != nil {
|
||||||
|
return nil, fmt.Errorf("merge groups: %w", err)
|
||||||
|
}
|
||||||
|
return []change{{seq: seq, regroup: &[2]string{loser, survivor}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// truthRid reads cid's committed group.
|
||||||
|
func (c *Client) truthRid(ctx context.Context, cid string) (string, error) {
|
||||||
|
var rid string
|
||||||
|
err := c.truth.DB().QueryRowContext(ctx, `SELECT rid FROM cid_group WHERE cid = ?`, cid).Scan(&rid)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return rid, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// votesFor reads every committed vote on key across cids, with signer uids.
|
||||||
|
func (c *Client) votesFor(ctx context.Context, cids []string, key string) ([]Vote, error) {
|
||||||
|
var out []Vote
|
||||||
|
for _, cid := range cids {
|
||||||
|
rows, err := c.truth.DB().QueryContext(ctx,
|
||||||
|
`SELECT c.pid, p.uid, c.value, c.ts FROM claim c JOIN principal p ON p.pid = c.pid
|
||||||
|
WHERE c.cid = ? AND c.key = ?`, cid, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var v Vote
|
||||||
|
var raw any
|
||||||
|
var ts int64
|
||||||
|
if err := rows.Scan(&v.Pid, &v.Uid, &raw, &ts); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v.Value, v.TS = scanValue(raw), uint64(ts)
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimKeys lists the distinct keys claimed on any of cids.
|
||||||
|
func (c *Client) claimKeys(ctx context.Context, cids []string) ([]string, error) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, cid := range cids {
|
||||||
|
rows, err := c.truth.DB().QueryContext(ctx, `SELECT DISTINCT key FROM claim WHERE cid = ?`, cid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var k string
|
||||||
|
if err := rows.Scan(&k); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !seen[k] {
|
||||||
|
seen[k] = true
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimsForCIDs returns every live signed claim on cids (tombstones excluded),
|
||||||
|
// optionally only for one key.
|
||||||
|
func (c *Client) ClaimsForCIDs(cids []string, key string) ([]claim.Claim, error) {
|
||||||
|
if _, err := c.truthStore(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
|
defer cancel()
|
||||||
|
var out []claim.Claim
|
||||||
|
for _, cid := range cids {
|
||||||
|
q := `SELECT c.cid, p.uid, c.key, c.value, c.ts, c.sig FROM claim c JOIN principal p ON p.pid = c.pid
|
||||||
|
WHERE c.cid = ? AND c.value IS NOT NULL`
|
||||||
|
args := []any{cid}
|
||||||
|
if key != "" {
|
||||||
|
q += ` AND c.key = ?`
|
||||||
|
args = append(args, key)
|
||||||
|
}
|
||||||
|
q += ` ORDER BY c.key, p.uid`
|
||||||
|
rows, err := c.truth.DB().QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var cl claim.Claim
|
||||||
|
var raw any
|
||||||
|
var ts int64
|
||||||
|
if err := rows.Scan(&cl.CID, &cl.UID, &cl.Key, &raw, &ts, &cl.Sig); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cl.Value, cl.TS = scanValue(raw), uint64(ts)
|
||||||
|
out = append(out, cl)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimsForRID returns the live claims of every cid in rid's group.
|
||||||
|
func (c *Client) ClaimsForRID(rid, key string) ([]claim.Claim, error) {
|
||||||
|
_, claims, err := c.RecordClaims(rid, key)
|
||||||
|
return claims, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordClaims returns the cids of rid's group (empty: no such record) and
|
||||||
|
// their live claims, optionally for one key.
|
||||||
|
func (c *Client) RecordClaims(rid, key string) ([]string, []claim.Claim, error) {
|
||||||
|
s, err := c.kv()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
|
defer cancel()
|
||||||
|
cids, err := cidsForRoot(ctx, s, rid)
|
||||||
|
if err != nil || len(cids) == 0 {
|
||||||
|
return cids, nil, err
|
||||||
|
}
|
||||||
|
claims, err := c.ClaimsForCIDs(cids, key)
|
||||||
|
return cids, claims, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveClaimsBySigner lists (cid, key) of every live claim uid has signed — the
|
||||||
|
// set DELETE /api/identity retracts.
|
||||||
|
func (c *Client) LiveClaimsBySigner(uid string) ([][2]string, error) {
|
||||||
|
s, err := c.kv()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pid, ok, err := c.pidOf(uid)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
|
defer cancel()
|
||||||
|
rows, err := s.DB().QueryContext(ctx, `SELECT cid, key FROM claim_by_pid WHERE pid = ? ORDER BY cid, key`, pid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out [][2]string
|
||||||
|
for rows.Next() {
|
||||||
|
var p [2]string
|
||||||
|
if err := rows.Scan(&p[0], &p[1]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) pidOf(uid string) (int64, bool, error) {
|
||||||
|
if _, err := c.truthStore(); err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
|
defer cancel()
|
||||||
|
var pid int64
|
||||||
|
err := c.truth.DB().QueryRowContext(ctx, `SELECT pid FROM principal WHERE uid = ?`, uid).Scan(&pid)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
return pid, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountClaims counts claim rows (tombstones included) — /api/stats' redisKeys.
|
||||||
|
func (c *Client) CountClaims() (int64, error) {
|
||||||
|
if _, err := c.truthStore(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
|
defer cancel()
|
||||||
|
var n int64
|
||||||
|
err := c.truth.DB().QueryRowContext(ctx, `SELECT count(*) FROM claim`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLocalField(key string) bool {
|
||||||
|
return key == "filePath" || key == "mtimeNano" || key == "sizeByte"
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimKeyAllowed rejects keys that would corrupt the merged view's key
|
||||||
|
// layout, and the removed per-algorithm CID shapes (cid_*, midhash256,
|
||||||
|
// canonical_cid — METADATA_KEYS.md §14.13: write the cids/<bareCid> key-set).
|
||||||
|
func claimKeyAllowed(key string) bool {
|
||||||
|
if strings.HasPrefix(key, "cid_") || key == "midhash256" || key == "canonical_cid" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !strings.HasPrefix(key, "/") && !strings.Contains(key, "\x00") && !isReservedFieldName(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadataFlatAs is GetMetadataFlat seen by viewer: the merged record with
|
||||||
|
// the viewer's own live text claims overlaid (their value wins in their view).
|
||||||
|
func (c *Client) GetMetadataFlatAs(rid, viewer string) (map[string]string, error) {
|
||||||
|
s, err := c.kv()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
|
defer cancel()
|
||||||
|
return c.readRecordAs(ctx, s, rid, viewer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) readRecordAs(ctx context.Context, s *kv.Store, rid, viewer string) (map[string]string, error) {
|
||||||
|
fields, err := readRecord(ctx, s, rid)
|
||||||
|
if err != nil || viewer == "" {
|
||||||
|
return fields, err
|
||||||
|
}
|
||||||
|
over, err := c.viewerOverrides(ctx, s, rid, viewer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(over) == 0 {
|
||||||
|
return fields, nil
|
||||||
|
}
|
||||||
|
if fields == nil {
|
||||||
|
fields = make(map[string]string, len(over))
|
||||||
|
}
|
||||||
|
for k, v := range over {
|
||||||
|
fields[k] = v
|
||||||
|
}
|
||||||
|
return fields, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewerOverrides returns the viewer's newest live text vote per key on the
|
||||||
|
// cids of rid's group.
|
||||||
|
func (c *Client) viewerOverrides(ctx context.Context, s *kv.Store, rid, viewer string) (map[string]string, error) {
|
||||||
|
pid, ok, err := c.pidOf(viewer)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cids, err := cidsForRoot(ctx, s, rid)
|
||||||
|
if err != nil || len(cids) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
newest := map[string]int64{}
|
||||||
|
for _, cid := range cids {
|
||||||
|
rows, err := s.DB().QueryContext(ctx, `SELECT key FROM claim_by_pid WHERE pid = ? AND cid = ?`, pid, cid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var keys []string
|
||||||
|
for rows.Next() {
|
||||||
|
var k string
|
||||||
|
if err := rows.Scan(&k); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
var raw any
|
||||||
|
var ts int64
|
||||||
|
if err := c.truth.DB().QueryRowContext(ctx,
|
||||||
|
`SELECT value, ts FROM claim WHERE cid = ? AND key = ? AND pid = ?`, cid, k, pid).Scan(&raw, &ts); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v := scanValue(raw); v.Kind == claim.KindText && ts > newest[k] {
|
||||||
|
out[k], newest[k] = v.Text, ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewerRIDs lists the records the viewer holds live claims on.
|
||||||
|
func (c *Client) viewerRIDs(ctx context.Context, viewer string) ([]string, error) {
|
||||||
|
pid, ok, err := c.pidOf(viewer)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := c.store
|
||||||
|
rows, err := s.DB().QueryContext(ctx,
|
||||||
|
`SELECT DISTINCT k.v FROM claim_by_pid b JOIN kv k ON k.k = 'cid:' || b.cid WHERE b.pid = ? LIMIT 10000`, pid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var rid string
|
||||||
|
if err := rows.Scan(&rid); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, rid)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeClaims is the stateless POST /api/merge: verify each claim (invalid
|
||||||
|
// ones are dropped and counted, never an error for the batch), merge per key
|
||||||
|
// with the §7 rule, and overlay viewer's own votes — those in the request and
|
||||||
|
// those this core holds (claim_by_pid) for the same cids. Returns the merged
|
||||||
|
// fields, the agreeing-signer count per key, and how many claims were rejected.
|
||||||
|
func (c *Client) MergeClaims(claims []claim.Claim, viewer string) (map[string]string, map[string]int, int, error) {
|
||||||
|
byKey := map[string][]Vote{}
|
||||||
|
rejected := 0
|
||||||
|
cids := map[string]bool{}
|
||||||
|
for i := range claims {
|
||||||
|
cl := &claims[i]
|
||||||
|
if err := cl.Verify(); err != nil {
|
||||||
|
rejected++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cids[cl.CID] = true
|
||||||
|
byKey[cl.Key] = append(byKey[cl.Key], Vote{Uid: cl.UID, Value: cl.Value, TS: cl.TS})
|
||||||
|
}
|
||||||
|
fields := make(map[string]string, len(byKey))
|
||||||
|
nsrc := make(map[string]int, len(byKey))
|
||||||
|
for key, votes := range byKey {
|
||||||
|
if res := Merge(votes, viewer); res.OK {
|
||||||
|
fields[key], nsrc[key] = res.Value, res.Nsrc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if viewer == "" {
|
||||||
|
return fields, nsrc, rejected, nil
|
||||||
|
}
|
||||||
|
s, err := c.kv()
|
||||||
|
if err != nil {
|
||||||
|
return fields, nsrc, rejected, nil // no local core: request overlay only
|
||||||
|
}
|
||||||
|
if _, err := c.truthStore(); err != nil {
|
||||||
|
return fields, nsrc, rejected, nil
|
||||||
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
|
defer cancel()
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for cid := range cids {
|
||||||
|
rid, _, err := s.Get(ctx, CIDIndexPrefix+cid)
|
||||||
|
if err != nil || rid == "" || seen[rid] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[rid] = true
|
||||||
|
over, err := c.viewerOverrides(ctx, s, rid, viewer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, rejected, err
|
||||||
|
}
|
||||||
|
for k, v := range over {
|
||||||
|
fields[k], nsrc[k] = v, 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields, nsrc, rejected, nil
|
||||||
|
}
|
||||||
+135
-430
@@ -1,481 +1,186 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
// User Data Layer (UDL) storage.
|
// The User Data Layer facade (/api/udl/*). Since v2.0.0 a user's data are
|
||||||
//
|
// ordinary claims signed by the user's key — plaintext for public-social keys
|
||||||
// Holds per-user, identity-signed records keyed by (uid, cid, key) — the
|
// (like, userRating, profile:*), ciphertext for private ones (seek, watched,
|
||||||
// subjective/per-user state that METADATA_KEYS.md rule #4 bars from the shared
|
// …). There is no separate store: these helpers read the claims of one signer
|
||||||
// /file/{cid} hash (likes, ratings, watch progress, local paths). Each record
|
// through the cache's claim_by_pid index and the truth claim table.
|
||||||
// is an opaque, client-signed (and, for private-tier keys, client-encrypted)
|
|
||||||
// CBOR blob; meta-core stores it verbatim and never needs the account key.
|
|
||||||
//
|
|
||||||
// Convergence is version-gated: a write is accepted only if its `version`
|
|
||||||
// strictly exceeds the stored one (a per-cell high-water-mark, replacing the
|
|
||||||
// op-log merge that used to live in meta-watch's store.rs).
|
|
||||||
//
|
|
||||||
// Redis model (all under the literal "udl:" prefix, mirroring the "file:" one):
|
|
||||||
//
|
|
||||||
// udl:rec:<uid>/<cid>/<key> HASH { record, version, ts, value? }
|
|
||||||
// udl:idx:user:<uid>:key:<key> SET of <cid> -> active_for_user_key (My List, Continue Watching)
|
|
||||||
// udl:idx:user:<uid>:cid:<cid> SET of <key> -> list_for_user_cid
|
|
||||||
// udl:idx:user:<uid>:cids SET of <cid> -> whole-profile export (recovery)
|
|
||||||
// udl:idx:cid:<cid>:key:<key> SET of <uid> -> aggregate
|
|
||||||
// udl:idx:cid:<cid>:users SET of <uid> -> uids_for_cid
|
|
||||||
//
|
|
||||||
// The `value` hash field is the plaintext JSON scalar for PUBLIC keys only
|
|
||||||
// (like/rating), used for aggregation; it is absent for private-tier keys
|
|
||||||
// (whose real value rides encrypted inside `record`) and for tombstones.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"database/sql"
|
||||||
"fmt"
|
"errors"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/metazla/meta-core/internal/claim"
|
||||||
|
"github.com/metazla/meta-core/internal/identity"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UDLCell is a decoded (uid, cid, key) record cell.
|
// UDLUserStats summarises one signer's live claims.
|
||||||
type UDLCell struct {
|
type UDLUserStats struct {
|
||||||
Record string // base64 CBOR, opaque + signed
|
Records int `json:"records"`
|
||||||
Version int64
|
Cids int `json:"cids"`
|
||||||
Ts int64
|
Keys int `json:"keys"`
|
||||||
Value string // plaintext JSON scalar, public keys only
|
|
||||||
HasValue bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDLUserKeyEntry is one row of active_for_user_key (all cids for a uid+key).
|
// UDLGetClaim returns the stored claim of uid on (cid, key), tombstone
|
||||||
type UDLUserKeyEntry struct {
|
// included (a client needs its ts to write a newer one). ok is false when the
|
||||||
Cid string `json:"cid"`
|
// signer never voted on it.
|
||||||
Record string `json:"record"`
|
func (c *Client) UDLGetClaim(uid, cid, key string) (claim.Claim, bool, error) {
|
||||||
Version int64 `json:"version"`
|
if _, err := c.truthStore(); err != nil {
|
||||||
Ts int64 `json:"ts"`
|
return claim.Claim{}, false, err
|
||||||
}
|
|
||||||
|
|
||||||
// UDLUserCidEntry is one row of list_for_user_cid (all keys for a uid+cid).
|
|
||||||
type UDLUserCidEntry struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Record string `json:"record"`
|
|
||||||
Version int64 `json:"version"`
|
|
||||||
Ts int64 `json:"ts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func udlRecKey(uid, cid, key string) string {
|
|
||||||
return fmt.Sprintf("udl:rec:%s/%s/%s", uid, cid, key)
|
|
||||||
}
|
|
||||||
func udlIdxUserKey(uid, key string) string { return fmt.Sprintf("udl:idx:user:%s:key:%s", uid, key) }
|
|
||||||
func udlIdxUserCid(uid, cid string) string { return fmt.Sprintf("udl:idx:user:%s:cid:%s", uid, cid) }
|
|
||||||
func udlIdxCidKey(cid, key string) string { return fmt.Sprintf("udl:idx:cid:%s:key:%s", cid, key) }
|
|
||||||
func udlIdxCidUsers(cid string) string { return fmt.Sprintf("udl:idx:cid:%s:users", cid) }
|
|
||||||
|
|
||||||
// udlIdxUserCids is the set of every cid a uid has ANY record for — the one
|
|
||||||
// index that makes "export this whole profile" a set read instead of a
|
|
||||||
// keyspace scan.
|
|
||||||
//
|
|
||||||
// The other four indexes each answer a question with one coordinate already
|
|
||||||
// known (a key, a cid). Cross-instance recovery knows neither: it has a uid and
|
|
||||||
// nothing else. Without this set the only ways to enumerate a profile are to
|
|
||||||
// iterate the hardcoded key list — which silently misses any key added later —
|
|
||||||
// or to SCAN `udl:rec:<uid>/*`, which is the full-keyspace-read pathology that
|
|
||||||
// already made `/api/udl/users/stats` admin-only.
|
|
||||||
func udlIdxUserCids(uid string) string { return fmt.Sprintf("udl:idx:user:%s:cids", uid) }
|
|
||||||
|
|
||||||
// udlUpsertScript performs the version-gated upsert + index maintenance
|
|
||||||
// atomically. Accepts (returns 1) only when the incoming version strictly
|
|
||||||
// exceeds the stored one; otherwise returns 0 (stale).
|
|
||||||
//
|
|
||||||
// KEYS: 1=cell 2=idx:user:key 3=idx:user:cid 4=idx:cid:key 5=idx:cid:users
|
|
||||||
// 6=idx:user:cids
|
|
||||||
// ARGV: 1=version 2=ts 3=record 4=value 5=hasValue("1"/"0")
|
|
||||||
// 6=cid 7=key 8=uid 9=tombstone("1"/"0")
|
|
||||||
//
|
|
||||||
// A tombstone still writes the cell — that is the CRDT state, and dropping it
|
|
||||||
// would let an older write resurrect the value — but it SREMs the cid from the
|
|
||||||
// user+key index. That index is what `active_for_user_key` returns, so leaving
|
|
||||||
// deleted cids in it means every My List / Continue Watching read carries every
|
|
||||||
// title ever un-liked or finished, forever. The other four indexes are left
|
|
||||||
// alone: they answer "who has an opinion about this cid", where a tombstone is
|
|
||||||
// still an answer.
|
|
||||||
//
|
|
||||||
// ⚠ `idx:user:cids` (KEYS[6]) in particular must NEVER be pruned on a
|
|
||||||
// tombstone, even though it looks symmetric with KEYS[2]. It is the export
|
|
||||||
// index: a cid dropped from it is a cid whose tombstone never travels, and a
|
|
||||||
// tombstone that does not travel is exactly how an un-liked title comes back
|
|
||||||
// from the dead on the other box — the peer still holds the older `like` at a
|
|
||||||
// lower version, and with no tombstone to beat it, nothing ever supersedes it.
|
|
||||||
var udlUpsertScript = redis.NewScript(`
|
|
||||||
local stored = redis.call('HGET', KEYS[1], 'version')
|
|
||||||
if stored and tonumber(stored) >= tonumber(ARGV[1]) then
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
redis.call('HSET', KEYS[1], 'version', ARGV[1], 'ts', ARGV[2], 'record', ARGV[3])
|
|
||||||
if ARGV[5] == '1' then
|
|
||||||
redis.call('HSET', KEYS[1], 'value', ARGV[4])
|
|
||||||
else
|
|
||||||
redis.call('HDEL', KEYS[1], 'value')
|
|
||||||
end
|
|
||||||
if ARGV[9] == '1' then
|
|
||||||
redis.call('SREM', KEYS[2], ARGV[6])
|
|
||||||
else
|
|
||||||
redis.call('SADD', KEYS[2], ARGV[6])
|
|
||||||
end
|
|
||||||
redis.call('SADD', KEYS[3], ARGV[7])
|
|
||||||
redis.call('SADD', KEYS[4], ARGV[8])
|
|
||||||
redis.call('SADD', KEYS[5], ARGV[8])
|
|
||||||
redis.call('SADD', KEYS[6], ARGV[6])
|
|
||||||
return 1
|
|
||||||
`)
|
|
||||||
|
|
||||||
// UDLUpsertIfNewer stores a record cell iff version strictly exceeds the stored
|
|
||||||
// one. publicValue/hasValue carry the plaintext JSON scalar for public keys
|
|
||||||
// (used by aggregation); pass hasValue=false for private-tier keys and
|
|
||||||
// tombstones. Set tombstone=true when the record deletes the cell, which also
|
|
||||||
// prunes the cid from the user+key index. Returns accepted=false when the write
|
|
||||||
// is stale.
|
|
||||||
func (c *Client) UDLUpsertIfNewer(uid, cid, key string, version, ts int64, record, publicValue string, hasValue, tombstone bool) (bool, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return false, fmt.Errorf("not connected")
|
|
||||||
}
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
cl := claim.Claim{CID: cid, UID: uid, Key: key}
|
||||||
keys := []string{
|
var raw any
|
||||||
c.buildKey(udlRecKey(uid, cid, key)),
|
var ts int64
|
||||||
c.buildKey(udlIdxUserKey(uid, key)),
|
err := c.truth.DB().QueryRowContext(ctx,
|
||||||
c.buildKey(udlIdxUserCid(uid, cid)),
|
`SELECT c.value, c.ts, c.sig FROM claim c JOIN principal p ON p.pid = c.pid
|
||||||
c.buildKey(udlIdxCidKey(cid, key)),
|
WHERE c.cid = ? AND c.key = ? AND p.uid = ?`, cid, key, uid).Scan(&raw, &ts, &cl.Sig)
|
||||||
c.buildKey(udlIdxCidUsers(cid)),
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
c.buildKey(udlIdxUserCids(uid)),
|
return claim.Claim{}, false, nil
|
||||||
}
|
}
|
||||||
hv := "0"
|
|
||||||
if hasValue {
|
|
||||||
hv = "1"
|
|
||||||
}
|
|
||||||
tomb := "0"
|
|
||||||
if tombstone {
|
|
||||||
tomb = "1"
|
|
||||||
}
|
|
||||||
res, err := udlUpsertScript.Run(ctx, c.client, keys, version, ts, record, publicValue, hv, cid, key, uid, tomb).Result()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("udl upsert failed: %w", err)
|
return claim.Claim{}, false, err
|
||||||
}
|
}
|
||||||
n, _ := res.(int64)
|
cl.Value, cl.TS = scanValue(raw), uint64(ts)
|
||||||
return n == 1, nil
|
return cl, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDLGetCell returns the record cell for (uid, cid, key). exists is false when
|
// UDLClaimsForUser returns uid's live claims, optionally narrowed to one cid
|
||||||
// no record has ever been written for the cell.
|
// and/or one key, ordered by cid then key.
|
||||||
func (c *Client) UDLGetCell(uid, cid, key string) (UDLCell, bool, error) {
|
func (c *Client) UDLClaimsForUser(uid, cid, key string) ([]claim.Claim, error) {
|
||||||
c.mu.RLock()
|
refs, err := c.LiveClaimsBySigner(uid)
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return UDLCell{}, false, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
return c.udlReadCell(ctx, udlRecKey(uid, cid, key))
|
|
||||||
}
|
|
||||||
|
|
||||||
// udlReadCell HMGETs a cell hash. Caller holds the lock. exists is false when
|
|
||||||
// the version field is absent (i.e. the hash does not exist).
|
|
||||||
func (c *Client) udlReadCell(ctx context.Context, recKey string) (UDLCell, bool, error) {
|
|
||||||
vals, err := c.client.HMGet(ctx, c.buildKey(recKey), "record", "version", "ts", "value").Result()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return UDLCell{}, false, fmt.Errorf("hmget failed: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
// vals: [record, version, ts, value] — nil for absent fields.
|
var out []claim.Claim
|
||||||
if len(vals) < 4 || vals[1] == nil {
|
for _, r := range refs {
|
||||||
return UDLCell{}, false, nil
|
if (cid != "" && r[0] != cid) || (key != "" && r[1] != key) {
|
||||||
}
|
continue
|
||||||
cell := UDLCell{}
|
}
|
||||||
if s, ok := vals[0].(string); ok {
|
cl, ok, err := c.UDLGetClaim(uid, r[0], r[1])
|
||||||
cell.Record = s
|
|
||||||
}
|
|
||||||
if s, ok := vals[1].(string); ok {
|
|
||||||
cell.Version, _ = strconv.ParseInt(s, 10, 64)
|
|
||||||
}
|
|
||||||
if s, ok := vals[2].(string); ok {
|
|
||||||
cell.Ts, _ = strconv.ParseInt(s, 10, 64)
|
|
||||||
}
|
|
||||||
if s, ok := vals[3].(string); ok {
|
|
||||||
cell.Value = s
|
|
||||||
cell.HasValue = true
|
|
||||||
}
|
|
||||||
return cell, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UDLListUserKey returns every cell for (uid, key) across all cids — the
|
|
||||||
// projection behind active_for_user_key (My List, Continue Watching). Includes
|
|
||||||
// tombstones; the caller decodes each record and filters.
|
|
||||||
func (c *Client) UDLListUserKey(uid, key string) ([]UDLUserKeyEntry, error) {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return nil, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cids, err := c.client.SMembers(ctx, c.buildKey(udlIdxUserKey(uid, key))).Result()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("smembers failed: %w", err)
|
|
||||||
}
|
|
||||||
out := make([]UDLUserKeyEntry, 0, len(cids))
|
|
||||||
for _, cid := range cids {
|
|
||||||
cell, ok, err := c.udlReadCell(ctx, udlRecKey(uid, cid, key))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !ok {
|
if ok && !cl.Value.IsNull() {
|
||||||
continue
|
out = append(out, cl)
|
||||||
}
|
}
|
||||||
out = append(out, UDLUserKeyEntry{Cid: cid, Record: cell.Record, Version: cell.Version, Ts: cell.Ts})
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDLListUserCid returns every cell for (uid, cid) across all keys — the
|
// UDLAllForUser pages uid's live claims by cid: claims of cids strictly after
|
||||||
// projection behind list_for_user_cid.
|
// `after`, stopping at a cid boundary once limit claims are collected.
|
||||||
func (c *Client) UDLListUserCid(uid, cid string) ([]UDLUserCidEntry, error) {
|
// nextCid is "" when the walk is complete.
|
||||||
c.mu.RLock()
|
func (c *Client) UDLAllForUser(uid, after string, limit int) ([]claim.Claim, string, error) {
|
||||||
defer c.mu.RUnlock()
|
all, err := c.UDLClaimsForUser(uid, "", "")
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return nil, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
keys, err := c.client.SMembers(ctx, c.buildKey(udlIdxUserCid(uid, cid))).Result()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("smembers failed: %w", err)
|
return nil, "", err
|
||||||
}
|
}
|
||||||
out := make([]UDLUserCidEntry, 0, len(keys))
|
var out []claim.Claim
|
||||||
for _, key := range keys {
|
for _, cl := range all {
|
||||||
cell, ok, err := c.udlReadCell(ctx, udlRecKey(uid, cid, key))
|
if after != "" && cl.CID <= after {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, UDLUserCidEntry{Key: key, Record: cell.Record, Version: cell.Version, Ts: cell.Ts})
|
if len(out) >= limit && cl.CID != out[len(out)-1].CID {
|
||||||
|
return out, out[len(out)-1].CID, nil
|
||||||
|
}
|
||||||
|
out = append(out, cl)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDLUserAllEntry is one row of a whole-profile export.
|
// UDLCountUserCids counts the distinct cids uid holds live claims on.
|
||||||
type UDLUserAllEntry struct {
|
func (c *Client) UDLCountUserCids(uid string) (int, error) {
|
||||||
Cid string `json:"cid"`
|
refs, err := c.LiveClaimsBySigner(uid)
|
||||||
Key string `json:"key"`
|
|
||||||
Record string `json:"record"`
|
|
||||||
Version int64 `json:"version"`
|
|
||||||
Ts int64 `json:"ts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UDLAllForUser returns every cell a uid owns, for cross-instance recovery.
|
|
||||||
//
|
|
||||||
// Ordered by cid (sorted, so paging is stable across calls even though the
|
|
||||||
// underlying index is a Redis SET), starting strictly after `afterCid`. A cid's
|
|
||||||
// keys are never split across a page: the page ends on a cid boundary once
|
|
||||||
// `limit` rows have been emitted, and `nextCid` is the last cid included — pass
|
|
||||||
// it back as `afterCid` to continue. `nextCid` is empty when the export is
|
|
||||||
// complete.
|
|
||||||
//
|
|
||||||
// Tombstones are included. They are the CRDT state that stops a deleted value
|
|
||||||
// resurrecting on the box that receives this export, so omitting them would
|
|
||||||
// make recovery actively wrong rather than merely incomplete.
|
|
||||||
func (c *Client) UDLAllForUser(uid, afterCid string, limit int) ([]UDLUserAllEntry, string, error) {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return nil, "", fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cids, err := c.client.SMembers(ctx, c.buildKey(udlIdxUserCids(uid))).Result()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("smembers failed: %w", err)
|
return 0, err
|
||||||
}
|
}
|
||||||
sort.Strings(cids)
|
seen := map[string]bool{}
|
||||||
|
for _, r := range refs {
|
||||||
out := make([]UDLUserAllEntry, 0, limit)
|
seen[r[0]] = true
|
||||||
nextCid := ""
|
|
||||||
for _, cid := range cids {
|
|
||||||
if afterCid != "" && cid <= afterCid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(out) >= limit {
|
|
||||||
nextCid = out[len(out)-1].Cid
|
|
||||||
break
|
|
||||||
}
|
|
||||||
keys, err := c.client.SMembers(ctx, c.buildKey(udlIdxUserCid(uid, cid))).Result()
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf("smembers failed: %w", err)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, key := range keys {
|
|
||||||
cell, ok, err := c.udlReadCell(ctx, udlRecKey(uid, cid, key))
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, UDLUserAllEntry{
|
|
||||||
Cid: cid, Key: key, Record: cell.Record, Version: cell.Version, Ts: cell.Ts,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out, nextCid, nil
|
return len(seen), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDLCountUserCids is the number of distinct cids a uid holds records for.
|
// UDLCidUsers lists the signers holding live claims on cid.
|
||||||
//
|
|
||||||
// O(1) (a Redis SCARD), which is what makes it usable as a per-push quota
|
|
||||||
// check on the gateway mirror — a quota that cost a keyspace walk would be a
|
|
||||||
// denial-of-service in its own right. Counts cids rather than cells because
|
|
||||||
// that is what the index holds; a profile is bounded by the titles it has
|
|
||||||
// touched, not by how many keys it has per title.
|
|
||||||
func (c *Client) UDLCountUserCids(uid string) (int64, error) {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return 0, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
n, err := c.client.SCard(ctx, c.buildKey(udlIdxUserCids(uid))).Result()
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("scard failed: %w", err)
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UDLBackfillUserCids rebuilds the `idx:user:<uid>:cids` sets for data written
|
|
||||||
// before that index existed, and reports how many cids it added.
|
|
||||||
//
|
|
||||||
// One-shot: the upsert script maintains the set from now on. It is a SCAN, but
|
|
||||||
// a targeted one (`udl:idx:user:*:cid:*`, which is one key per (uid,cid), not
|
|
||||||
// per record) run once at boot rather than per read — the distinction that
|
|
||||||
// makes the difference between a migration and the pathology this index exists
|
|
||||||
// to avoid.
|
|
||||||
func (c *Client) UDLBackfillUserCids() (int, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return 0, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
pattern := c.buildKey("udl:idx:user:*:cid:*")
|
|
||||||
prefix := c.buildKey("udl:idx:user:")
|
|
||||||
added := 0
|
|
||||||
var cursor uint64
|
|
||||||
for {
|
|
||||||
keys, next, err := c.client.Scan(ctx, cursor, pattern, 500).Result()
|
|
||||||
if err != nil {
|
|
||||||
return added, fmt.Errorf("scan failed: %w", err)
|
|
||||||
}
|
|
||||||
for _, k := range keys {
|
|
||||||
// <prefix><uid>:cid:<cid> — split on the FIRST separator, not the
|
|
||||||
// last. A uid is multibase base58btc, whose alphabet contains no
|
|
||||||
// colon at all, so the first ":cid:" always ends the uid. A cid
|
|
||||||
// that happens to contain ":cid:" in its own name would make the
|
|
||||||
// last occurrence split in the wrong place.
|
|
||||||
rest := strings.TrimPrefix(k, prefix)
|
|
||||||
i := strings.Index(rest, ":cid:")
|
|
||||||
if i < 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
uid, cid := rest[:i], rest[i+len(":cid:"):]
|
|
||||||
if uid == "" || cid == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
n, err := c.client.SAdd(ctx, c.buildKey(udlIdxUserCids(uid)), cid).Result()
|
|
||||||
if err != nil {
|
|
||||||
return added, fmt.Errorf("sadd failed: %w", err)
|
|
||||||
}
|
|
||||||
added += int(n)
|
|
||||||
}
|
|
||||||
cursor = next
|
|
||||||
if cursor == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return added, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UDLCidUsers returns every uid that has any record for cid — uids_for_cid.
|
|
||||||
func (c *Client) UDLCidUsers(cid string) ([]string, error) {
|
func (c *Client) UDLCidUsers(cid string) ([]string, error) {
|
||||||
c.mu.RLock()
|
claims, err := c.ClaimsForCIDs([]string{cid}, "")
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return nil, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
uids, err := c.client.SMembers(ctx, c.buildKey(udlIdxCidUsers(cid))).Result()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("smembers failed: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
return uids, nil
|
seen := map[string]bool{}
|
||||||
|
out := []string{}
|
||||||
|
for _, cl := range claims {
|
||||||
|
if !seen[cl.UID] {
|
||||||
|
seen[cl.UID] = true
|
||||||
|
out = append(out, cl.UID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDLPublicValues returns the live plaintext public JSON scalar values for
|
// UDLPublicValues returns the plaintext values of every live claim on
|
||||||
// (cid, key) across all uids — the input to aggregation. Cells without a value
|
// (cid, key) — the input of /api/udl/cid/{cid}/aggregate.
|
||||||
// field (private-tier or tombstoned) are skipped.
|
|
||||||
func (c *Client) UDLPublicValues(cid, key string) ([]string, error) {
|
func (c *Client) UDLPublicValues(cid, key string) ([]string, error) {
|
||||||
c.mu.RLock()
|
claims, err := c.ClaimsForCIDs([]string{cid}, key)
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.client == nil {
|
|
||||||
return nil, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
uids, err := c.client.SMembers(ctx, c.buildKey(udlIdxCidKey(cid, key))).Result()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("smembers failed: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make([]string, 0, len(uids))
|
out := []string{}
|
||||||
for _, uid := range uids {
|
for _, cl := range claims {
|
||||||
v, err := c.client.HGet(ctx, c.buildKey(udlRecKey(uid, cid, key)), "value").Result()
|
if cl.Value.Kind == claim.KindText {
|
||||||
if err == redis.Nil {
|
out = append(out, cl.Value.Text)
|
||||||
continue // private-tier or tombstoned
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDLAllUserStats summarises every signer that holds live claims, keyed by
|
||||||
|
// uid. Callers that want only human accounts intersect it with the account
|
||||||
|
// roster.
|
||||||
|
func (c *Client) UDLAllUserStats() (map[string]UDLUserStats, error) {
|
||||||
|
s, err := c.kv()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := c.truthStore(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx, cancel := opCtx()
|
||||||
|
defer cancel()
|
||||||
|
rows, err := s.DB().QueryContext(ctx,
|
||||||
|
`SELECT pid, count(*), count(DISTINCT cid), count(DISTINCT key) FROM claim_by_pid GROUP BY pid`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
type agg struct {
|
||||||
|
pid int64
|
||||||
|
st UDLUserStats
|
||||||
|
}
|
||||||
|
var aggs []agg
|
||||||
|
for rows.Next() {
|
||||||
|
var a agg
|
||||||
|
if err := rows.Scan(&a.pid, &a.st.Records, &a.st.Cids, &a.st.Keys); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aggs = append(aggs, a)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[string]UDLUserStats, len(aggs))
|
||||||
|
for _, a := range aggs {
|
||||||
|
var uid string
|
||||||
|
if err := c.truth.DB().QueryRowContext(ctx, `SELECT uid FROM principal WHERE pid = ?`, a.pid).Scan(&uid); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if identity.ValidUID(uid) {
|
||||||
|
out[uid] = a.st
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("hget failed: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
// Account-scoped UDL administration: what a uid holds, and removing all of it.
|
|
||||||
//
|
|
||||||
// Both operations enumerate the same thing — every `udl:rec:<uid>/<cid>/<key>`
|
|
||||||
// cell — because there is no per-user cid index to consult. The Redis model
|
|
||||||
// (see udl.go) indexes user+key → cids and user+cid → keys, which answer
|
|
||||||
// "what is in My List", not "everything this account ever wrote". Adding a
|
|
||||||
// third index would put a write on every UDL upsert to serve two admin calls,
|
|
||||||
// so these SCAN instead and accept being O(keyspace).
|
|
||||||
//
|
|
||||||
// That cost is why UDLAllUserStats exists as a bulk call rather than a per-uid
|
|
||||||
// one: the accounts page needs a number for every account, and MATCH filters
|
|
||||||
// server-side without indexing, so N per-uid calls would walk the keyspace N
|
|
||||||
// times. One walk buckets them all.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UDLUserStats counts what one account holds. Records is the number of cells,
|
|
||||||
// including tombstones — a retracted rating is still a row this account owns
|
|
||||||
// and still something a purge has to remove.
|
|
||||||
type UDLUserStats struct {
|
|
||||||
Records int `json:"records"`
|
|
||||||
Cids int `json:"cids"`
|
|
||||||
Keys int `json:"keys"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// udlScanBatch is the SCAN COUNT hint, matching the rest of this package.
|
|
||||||
const udlScanBatch = 1000
|
|
||||||
|
|
||||||
// safeUIDForPattern rejects anything that would change the meaning of a SCAN
|
|
||||||
// glob or reach across the key structure. meta-core's uids are alphanumeric
|
|
||||||
// (identity.validUID), so this only ever fires on a hostile or malformed one —
|
|
||||||
// but it fires here, in the layer that builds the pattern, rather than relying
|
|
||||||
// on every caller having validated first.
|
|
||||||
func safeUIDForPattern(uid string) error {
|
|
||||||
if uid == "" {
|
|
||||||
return fmt.Errorf("empty uid")
|
|
||||||
}
|
|
||||||
if strings.ContainsAny(uid, "*?[]\\/:") {
|
|
||||||
return fmt.Errorf("uid contains characters that are not valid in a uid: %q", uid)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitUDLRecSuffix splits the "<uid>/<cid>/<key>" tail of a udl:rec key.
|
|
||||||
//
|
|
||||||
// SplitN with a limit of 3 so a key containing a slash survives intact. cids
|
|
||||||
// never contain one; keys are a controlled vocabulary today, but a namespaced
|
|
||||||
// one arriving later must not silently truncate here — a truncated key would
|
|
||||||
// make the purge SREM the wrong index member and leave the real one behind.
|
|
||||||
func splitUDLRecSuffix(suffix string) (uid, cid, key string, ok bool) {
|
|
||||||
parts := strings.SplitN(suffix, "/", 3)
|
|
||||||
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
|
|
||||||
return "", "", "", false
|
|
||||||
}
|
|
||||||
return parts[0], parts[1], parts[2], true
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanKeys walks the keyspace for pattern and returns every match. Caller holds
|
|
||||||
// the lock.
|
|
||||||
//
|
|
||||||
// SCAN can return the same key more than once across cursor iterations (it
|
|
||||||
// guarantees no misses, not no duplicates), so callers that count must
|
|
||||||
// de-duplicate — both callers below collect into maps or sets for that reason.
|
|
||||||
func (c *Client) scanKeys(ctx context.Context, pattern string) ([]string, error) {
|
|
||||||
var (
|
|
||||||
out []string
|
|
||||||
cursor uint64
|
|
||||||
)
|
|
||||||
for {
|
|
||||||
keys, next, err := c.client.Scan(ctx, cursor, pattern, udlScanBatch).Result()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scan %q: %w", pattern, err)
|
|
||||||
}
|
|
||||||
out = append(out, keys...)
|
|
||||||
cursor = next
|
|
||||||
if cursor == 0 {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UDLUserCells returns every (cid, key) pair this account holds a cell for.
|
|
||||||
// De-duplicated, since SCAN may repeat a key.
|
|
||||||
func (c *Client) UDLUserCells(uid string) (map[string]string, error) {
|
|
||||||
// Keyed by the full "cid/key" tail so the pair stays a pair; the value is
|
|
||||||
// the cid, which the purge needs grouped.
|
|
||||||
if err := safeUIDForPattern(uid); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
if c.client == nil {
|
|
||||||
return nil, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
prefix := c.buildKey("udl:rec:")
|
|
||||||
keys, err := c.scanKeys(ctx, prefix+uid+"/*")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make(map[string]string, len(keys))
|
|
||||||
for _, full := range keys {
|
|
||||||
gotUID, cid, key, ok := splitUDLRecSuffix(strings.TrimPrefix(full, prefix))
|
|
||||||
// The glob can only match this uid, but a uid that is a prefix of
|
|
||||||
// another would still be caught by the '/' in the pattern; check anyway
|
|
||||||
// so a malformed key can never be attributed to the wrong account.
|
|
||||||
if !ok || gotUID != uid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[cid+"/"+key] = cid
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UDLAllUserStats counts every account's cells in one keyspace walk.
|
|
||||||
//
|
|
||||||
// Returns only accounts that actually hold records; a uid absent from the map
|
|
||||||
// holds none. Callers render a zero for those rather than treating it as an
|
|
||||||
// error — an account created a minute ago legitimately has nothing.
|
|
||||||
func (c *Client) UDLAllUserStats() (map[string]UDLUserStats, error) {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
if c.client == nil {
|
|
||||||
return nil, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
prefix := c.buildKey("udl:rec:")
|
|
||||||
keys, err := c.scanKeys(ctx, prefix+"*")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sets rather than counters: SCAN may hand back the same key twice, which
|
|
||||||
// would otherwise inflate every number on the page.
|
|
||||||
type acc struct {
|
|
||||||
cells map[string]struct{}
|
|
||||||
cids map[string]struct{}
|
|
||||||
keys map[string]struct{}
|
|
||||||
}
|
|
||||||
byUID := make(map[string]*acc)
|
|
||||||
for _, full := range keys {
|
|
||||||
uid, cid, key, ok := splitUDLRecSuffix(strings.TrimPrefix(full, prefix))
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
a := byUID[uid]
|
|
||||||
if a == nil {
|
|
||||||
a = &acc{
|
|
||||||
cells: make(map[string]struct{}),
|
|
||||||
cids: make(map[string]struct{}),
|
|
||||||
keys: make(map[string]struct{}),
|
|
||||||
}
|
|
||||||
byUID[uid] = a
|
|
||||||
}
|
|
||||||
a.cells[cid+"/"+key] = struct{}{}
|
|
||||||
a.cids[cid] = struct{}{}
|
|
||||||
a.keys[key] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make(map[string]UDLUserStats, len(byUID))
|
|
||||||
for uid, a := range byUID {
|
|
||||||
out[uid] = UDLUserStats{Records: len(a.cells), Cids: len(a.cids), Keys: len(a.keys)}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UDLPurgeUser removes every trace of one account from the User Data Layer:
|
|
||||||
// its cells, its three user-scoped indexes, and its membership in the shared
|
|
||||||
// cid-scoped indexes. Returns what was removed.
|
|
||||||
//
|
|
||||||
// The shared indexes are the part that is easy to miss and expensive to leave.
|
|
||||||
// `udl:idx:cid:<cid>:key:<key>` and `udl:idx:cid:<cid>:users` are keyed by cid,
|
|
||||||
// not by uid, so a purge that only deleted `udl:rec:<uid>/*` would leave this
|
|
||||||
// uid listed as an opinion-holder on every title it ever touched. Aggregation
|
|
||||||
// then HGETs a cell that no longer exists — UDLPublicValues skips it, so the
|
|
||||||
// rating average stays right, but uids_for_cid keeps naming a deleted account
|
|
||||||
// forever and every aggregate read pays for the lookup.
|
|
||||||
//
|
|
||||||
// Idempotent: purging an account with nothing stored is a no-op returning
|
|
||||||
// zeroes, so a retry after a partial failure is safe.
|
|
||||||
func (c *Client) UDLPurgeUser(uid string) (UDLUserStats, error) {
|
|
||||||
cells, err := c.UDLUserCells(uid)
|
|
||||||
if err != nil {
|
|
||||||
return UDLUserStats{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
if c.client == nil {
|
|
||||||
return UDLUserStats{}, fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
stats := UDLUserStats{Records: len(cells)}
|
|
||||||
distinctCids := make(map[string]struct{})
|
|
||||||
distinctKeys := make(map[string]struct{})
|
|
||||||
|
|
||||||
pipe := c.client.Pipeline()
|
|
||||||
for cidKey, cid := range cells {
|
|
||||||
key := strings.TrimPrefix(cidKey, cid+"/")
|
|
||||||
distinctCids[cid] = struct{}{}
|
|
||||||
distinctKeys[key] = struct{}{}
|
|
||||||
pipe.Del(ctx, c.buildKey(udlRecKey(uid, cid, key)))
|
|
||||||
pipe.SRem(ctx, c.buildKey(udlIdxCidKey(cid, key)), uid)
|
|
||||||
}
|
|
||||||
for cid := range distinctCids {
|
|
||||||
pipe.SRem(ctx, c.buildKey(udlIdxCidUsers(cid)), uid)
|
|
||||||
}
|
|
||||||
stats.Cids = len(distinctCids)
|
|
||||||
stats.Keys = len(distinctKeys)
|
|
||||||
|
|
||||||
// The three user-scoped indexes (`:key:*`, `:cid:*`, `:cids`) are enumerable
|
|
||||||
// directly, and are dropped whole rather than member-by-member. The glob is
|
|
||||||
// what keeps this correct as indexes are added — `:cids` needed no change
|
|
||||||
// here when it landed. Scanning them separately also catches
|
|
||||||
// index entries whose cell is already gone — exactly the state a previously
|
|
||||||
// half-finished purge leaves behind.
|
|
||||||
idxPrefix := c.buildKey("udl:idx:user:") + uid + ":"
|
|
||||||
idxKeys, err := c.scanKeys(ctx, idxPrefix+"*")
|
|
||||||
if err != nil {
|
|
||||||
return UDLUserStats{}, err
|
|
||||||
}
|
|
||||||
for _, k := range idxKeys {
|
|
||||||
pipe.Del(ctx, k)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := pipe.Exec(ctx); err != nil {
|
|
||||||
return UDLUserStats{}, fmt.Errorf("purge pipeline: %w", err)
|
|
||||||
}
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// seedCell writes one live UDL cell the way a real client would.
|
|
||||||
func seedCell(t *testing.T, c *Client, uid, cid, key string) {
|
|
||||||
t.Helper()
|
|
||||||
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 100, "rec", "", false, false); err != nil || !ok {
|
|
||||||
t.Fatalf("seed %s/%s/%s: ok=%v err=%v", uid, cid, key, ok, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The purge has to reach the cid-scoped indexes, not just the user's own rows.
|
|
||||||
//
|
|
||||||
// This is the failure that motivated the whole endpoint: deleting an account
|
|
||||||
// while leaving `udl:idx:cid:<cid>:users` intact means every aggregate read on
|
|
||||||
// every title that account ever touched keeps naming a uid nobody can resolve,
|
|
||||||
// forever — and the rows are unreachable through any API, so nothing will ever
|
|
||||||
// clean them up.
|
|
||||||
func TestUDLPurgeUser_RemovesCellsAndBothIndexFamilies(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
seedCell(t, c, "zAlice", "cidOne", "like")
|
|
||||||
seedCell(t, c, "zAlice", "cidOne", "rating")
|
|
||||||
seedCell(t, c, "zAlice", "cidTwo", "seek")
|
|
||||||
|
|
||||||
stats, err := c.UDLPurgeUser("zAlice")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("purge: %v", err)
|
|
||||||
}
|
|
||||||
if stats.Records != 3 || stats.Cids != 2 || stats.Keys != 3 {
|
|
||||||
t.Fatalf("stats = %+v, want 3 records / 2 cids / 3 keys", stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cells gone.
|
|
||||||
if _, ok, err := c.UDLGetCell("zAlice", "cidOne", "like"); err != nil || ok {
|
|
||||||
t.Fatalf("cell survived the purge: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
// User-scoped indexes gone.
|
|
||||||
if entries, err := c.UDLListUserKey("zAlice", "like"); err != nil || len(entries) != 0 {
|
|
||||||
t.Fatalf("user+key index survived: %d entries, err=%v", len(entries), err)
|
|
||||||
}
|
|
||||||
if entries, err := c.UDLListUserCid("zAlice", "cidOne"); err != nil || len(entries) != 0 {
|
|
||||||
t.Fatalf("user+cid index survived: %d entries, err=%v", len(entries), err)
|
|
||||||
}
|
|
||||||
// Cid-scoped index gone — the one that is easy to miss.
|
|
||||||
for _, cid := range []string{"cidOne", "cidTwo"} {
|
|
||||||
uids, err := c.UDLCidUsers(cid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("cid users %s: %v", cid, err)
|
|
||||||
}
|
|
||||||
for _, u := range uids {
|
|
||||||
if u == "zAlice" {
|
|
||||||
t.Fatalf("%s still lists the deleted account in uids_for_cid", cid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Purging one account must not touch another's rows, including on a cid they
|
|
||||||
// both have an opinion about — where the two share an index set and a careless
|
|
||||||
// DEL of the whole key would take the survivor's membership with it.
|
|
||||||
func TestUDLPurgeUser_LeavesOtherAccountsIntact(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
seedCell(t, c, "zAlice", "shared", "rating")
|
|
||||||
seedCell(t, c, "zBob", "shared", "rating")
|
|
||||||
|
|
||||||
if _, err := c.UDLPurgeUser("zAlice"); err != nil {
|
|
||||||
t.Fatalf("purge: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok, err := c.UDLGetCell("zBob", "shared", "rating"); err != nil || !ok {
|
|
||||||
t.Fatalf("bob's cell must survive alice's purge: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
uids, err := c.UDLCidUsers("shared")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("cid users: %v", err)
|
|
||||||
}
|
|
||||||
if len(uids) != 1 || uids[0] != "zBob" {
|
|
||||||
t.Fatalf("uids_for_cid = %v, want exactly [zBob]", uids)
|
|
||||||
}
|
|
||||||
entries, err := c.UDLListUserKey("zBob", "rating")
|
|
||||||
if err != nil || len(entries) != 1 {
|
|
||||||
t.Fatalf("bob's user+key index must survive: %d entries, err=%v", len(entries), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A uid that is a prefix of another must not drag its neighbour's rows out.
|
|
||||||
// The key layout separates them with '/' and ':', so this passes only as long
|
|
||||||
// as the scan patterns keep those separators.
|
|
||||||
func TestUDLPurgeUser_DoesNotMatchUIDsByPrefix(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
seedCell(t, c, "zAlice", "cidOne", "like")
|
|
||||||
seedCell(t, c, "zAliceExtra", "cidOne", "like")
|
|
||||||
|
|
||||||
stats, err := c.UDLPurgeUser("zAlice")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("purge: %v", err)
|
|
||||||
}
|
|
||||||
if stats.Records != 1 {
|
|
||||||
t.Fatalf("purged %d records, want exactly alice's 1", stats.Records)
|
|
||||||
}
|
|
||||||
if _, ok, err := c.UDLGetCell("zAliceExtra", "cidOne", "like"); err != nil || !ok {
|
|
||||||
t.Fatalf("zAliceExtra's cell must survive: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tombstoned cells still belong to the account. They are absent from the
|
|
||||||
// user+key index by design (see udl.go), so a purge that enumerated *that*
|
|
||||||
// index instead of the cells would leave every retracted rating behind — the
|
|
||||||
// rows most likely to have accumulated over an account's life.
|
|
||||||
func TestUDLPurgeUser_RemovesTombstonedCellsToo(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
seedCell(t, c, "zAlice", "cidOne", "like")
|
|
||||||
if ok, err := c.UDLUpsertIfNewer("zAlice", "cidOne", "like", 2, 200, "rec-tomb", "", false, true); err != nil || !ok {
|
|
||||||
t.Fatalf("tombstone: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
// Precondition: the tombstone left the cell but dropped it from the index.
|
|
||||||
if entries, _ := c.UDLListUserKey("zAlice", "like"); len(entries) != 0 {
|
|
||||||
t.Fatalf("precondition: tombstone should be out of the user+key index")
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := c.UDLPurgeUser("zAlice")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("purge: %v", err)
|
|
||||||
}
|
|
||||||
if stats.Records != 1 {
|
|
||||||
t.Fatalf("purged %d records, want the 1 tombstoned cell", stats.Records)
|
|
||||||
}
|
|
||||||
if _, ok, _ := c.UDLGetCell("zAlice", "cidOne", "like"); ok {
|
|
||||||
t.Fatal("tombstoned cell survived the purge")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry-safe: the delete handler purges before removing the key, so a caller
|
|
||||||
// that retries after a partial failure runs this a second time.
|
|
||||||
func TestUDLPurgeUser_IsIdempotent(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
seedCell(t, c, "zAlice", "cidOne", "like")
|
|
||||||
|
|
||||||
if _, err := c.UDLPurgeUser("zAlice"); err != nil {
|
|
||||||
t.Fatalf("first purge: %v", err)
|
|
||||||
}
|
|
||||||
stats, err := c.UDLPurgeUser("zAlice")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second purge must be a no-op, got: %v", err)
|
|
||||||
}
|
|
||||||
if stats.Records != 0 || stats.Cids != 0 {
|
|
||||||
t.Fatalf("second purge = %+v, want zeroes", stats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUDLPurgeUser_RefusesGlobInjection(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
seedCell(t, c, "zAlice", "cidOne", "like")
|
|
||||||
|
|
||||||
// "*" would otherwise match every account in the keyspace — a wildcard
|
|
||||||
// purge dressed up as a uid.
|
|
||||||
for _, uid := range []string{"*", "z*", "", "z:Alice", "z/Alice"} {
|
|
||||||
if _, err := c.UDLPurgeUser(uid); err == nil {
|
|
||||||
t.Errorf("uid %q must be refused before it reaches a SCAN pattern", uid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok, _ := c.UDLGetCell("zAlice", "cidOne", "like"); !ok {
|
|
||||||
t.Fatal("a refused purge must not have deleted anything")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUDLAllUserStats_CountsEachAccountSeparately(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
seedCell(t, c, "zAlice", "cidOne", "like")
|
|
||||||
seedCell(t, c, "zAlice", "cidOne", "rating")
|
|
||||||
seedCell(t, c, "zAlice", "cidTwo", "like")
|
|
||||||
seedCell(t, c, "zBob", "cidOne", "seek")
|
|
||||||
|
|
||||||
stats, err := c.UDLAllUserStats()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stats: %v", err)
|
|
||||||
}
|
|
||||||
if got := stats["zAlice"]; got.Records != 3 || got.Cids != 2 || got.Keys != 2 {
|
|
||||||
t.Fatalf("alice = %+v, want 3 records / 2 cids / 2 keys", got)
|
|
||||||
}
|
|
||||||
if got := stats["zBob"]; got.Records != 1 || got.Cids != 1 || got.Keys != 1 {
|
|
||||||
t.Fatalf("bob = %+v, want 1/1/1", got)
|
|
||||||
}
|
|
||||||
// An account with nothing stored is simply absent — the caller renders 0.
|
|
||||||
if _, present := stats["zNobody"]; present {
|
|
||||||
t.Fatal("an account with no records must not appear in the map")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The stats and the purge must agree: the number shown in the delete
|
|
||||||
// confirmation is the number of rows the delete then removes.
|
|
||||||
func TestUDLAllUserStats_AgreesWithWhatPurgeRemoves(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
seedCell(t, c, "zAlice", "cidOne", "like")
|
|
||||||
seedCell(t, c, "zAlice", "cidTwo", "rating")
|
|
||||||
seedCell(t, c, "zAlice", "cidTwo", "seek")
|
|
||||||
|
|
||||||
before, err := c.UDLAllUserStats()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stats: %v", err)
|
|
||||||
}
|
|
||||||
purged, err := c.UDLPurgeUser("zAlice")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("purge: %v", err)
|
|
||||||
}
|
|
||||||
if before["zAlice"] != purged {
|
|
||||||
t.Fatalf("stats said %+v but purge removed %+v", before["zAlice"], purged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The key prefix (multi-tenant Redis) must not leak into the parsed uid, or
|
|
||||||
// every account on a prefixed node would be reported under a mangled name and
|
|
||||||
// the purge would find nothing.
|
|
||||||
func TestUDLAdmin_RespectsKeyPrefix(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "tenantA:")
|
|
||||||
|
|
||||||
seedCell(t, c, "zAlice", "cidOne", "like")
|
|
||||||
|
|
||||||
stats, err := c.UDLAllUserStats()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stats: %v", err)
|
|
||||||
}
|
|
||||||
if got := stats["zAlice"]; got.Records != 1 {
|
|
||||||
t.Fatalf("stats under a prefix = %+v, want 1 record for zAlice", stats)
|
|
||||||
}
|
|
||||||
if s, err := c.UDLPurgeUser("zAlice"); err != nil || s.Records != 1 {
|
|
||||||
t.Fatalf("purge under a prefix: %+v err=%v", s, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSplitUDLRecSuffix_KeepsSlashesInTheKey(t *testing.T) {
|
|
||||||
// A namespaced key arriving later must not be truncated: the purge SREMs
|
|
||||||
// `udl:idx:cid:<cid>:key:<key>`, so a half-read key removes the wrong
|
|
||||||
// member and leaves the real one behind.
|
|
||||||
uid, cid, key, ok := splitUDLRecSuffix("zAlice/cidOne/ns/sub:key")
|
|
||||||
if !ok || uid != "zAlice" || cid != "cidOne" || key != "ns/sub:key" {
|
|
||||||
t.Fatalf("got (%q,%q,%q,%v)", uid, cid, key, ok)
|
|
||||||
}
|
|
||||||
for _, bad := range []string{"", "zAlice", "zAlice/cidOne", "zAlice//key", "/cid/key"} {
|
|
||||||
if _, _, _, ok := splitUDLRecSuffix(bad); ok {
|
|
||||||
t.Errorf("%q should not parse", bad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The whole-profile export is what cross-instance recovery reads: a box that
|
|
||||||
// has just imported a secret key knows the uid and nothing else — not which
|
|
||||||
// cids the profile touched, not which keys. Every one of these tests is about
|
|
||||||
// that one caller.
|
|
||||||
|
|
||||||
func TestUDLAllForUser_ReturnsEveryCellAcrossCidsAndKeys(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
const uid = "zAlice"
|
|
||||||
writes := []struct{ cid, key, rec string }{
|
|
||||||
{"bagcsaaaaone", "like", "rec-one-like"},
|
|
||||||
{"bagcsaaaaone", "seek", "rec-one-seek"},
|
|
||||||
{"bagcsaaaatwo", "rating", "rec-two-rating"},
|
|
||||||
{"bagcsaaaatwo", "profile:name", "rec-two-name"},
|
|
||||||
{"bagcsaaathree", "watched", "rec-three-watched"},
|
|
||||||
}
|
|
||||||
for _, w := range writes {
|
|
||||||
if ok, err := c.UDLUpsertIfNewer(uid, w.cid, w.key, 1, 100, w.rec, "", false, false); err != nil || !ok {
|
|
||||||
t.Fatalf("upsert %s/%s: ok=%v err=%v", w.cid, w.key, ok, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, next, err := c.UDLAllForUser(uid, "", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("export: %v", err)
|
|
||||||
}
|
|
||||||
if next != "" {
|
|
||||||
t.Fatalf("export of 5 rows should be complete, got nextCid=%q", next)
|
|
||||||
}
|
|
||||||
if len(entries) != len(writes) {
|
|
||||||
t.Fatalf("want %d rows, got %d: %+v", len(writes), len(entries), entries)
|
|
||||||
}
|
|
||||||
seen := map[string]string{}
|
|
||||||
for _, e := range entries {
|
|
||||||
seen[e.Cid+"/"+e.Key] = e.Record
|
|
||||||
}
|
|
||||||
for _, w := range writes {
|
|
||||||
if got := seen[w.cid+"/"+w.key]; got != w.rec {
|
|
||||||
t.Errorf("%s/%s: want record %q, got %q", w.cid, w.key, w.rec, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The failure this guards is silent and total: a uid whose cids were never
|
|
||||||
// indexed exports as empty, and recovery reports success having restored
|
|
||||||
// nothing.
|
|
||||||
func TestUDLAllForUser_IsEmptyForAnUnknownUid(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
if ok, err := c.UDLUpsertIfNewer("zAlice", "bagcsaaaaone", "like", 1, 100, "rec", "", false, false); err != nil || !ok {
|
|
||||||
t.Fatalf("upsert: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
entries, _, err := c.UDLAllForUser("zBob", "", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("export: %v", err)
|
|
||||||
}
|
|
||||||
if len(entries) != 0 {
|
|
||||||
t.Fatalf("Bob owns nothing; got %+v", entries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ⚠ The tombstone rule, and the reason the cids index is never pruned.
|
|
||||||
//
|
|
||||||
// A tombstone must EXPORT. It is the only thing that can beat the older live
|
|
||||||
// value the other box still holds at a lower version — drop it from the export
|
|
||||||
// and an un-liked title comes back from the dead on every peer that already
|
|
||||||
// saw the `like`. This is the one place the cids index deliberately diverges
|
|
||||||
// from the user+key index, which does prune.
|
|
||||||
func TestUDLAllForUser_ExportsTombstones(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
const (
|
|
||||||
uid = "zAlice"
|
|
||||||
cid = "bagcsaaaaone"
|
|
||||||
key = "like"
|
|
||||||
)
|
|
||||||
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 100, "rec-liked", "", false, false); err != nil || !ok {
|
|
||||||
t.Fatalf("live upsert: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 2, 200, "rec-unliked", "", false, true); err != nil || !ok {
|
|
||||||
t.Fatalf("tombstone upsert: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The user+key index prunes it — that is what keeps My List short.
|
|
||||||
live, err := c.UDLListUserKey(uid, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("active_for_user_key: %v", err)
|
|
||||||
}
|
|
||||||
if len(live) != 0 {
|
|
||||||
t.Fatalf("tombstoned cid should leave the user+key index, got %+v", live)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The export must still carry it, at the tombstone's version.
|
|
||||||
entries, _, err := c.UDLAllForUser(uid, "", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("export: %v", err)
|
|
||||||
}
|
|
||||||
if len(entries) != 1 {
|
|
||||||
t.Fatalf("tombstone must export, got %+v", entries)
|
|
||||||
}
|
|
||||||
if entries[0].Record != "rec-unliked" || entries[0].Version != 2 {
|
|
||||||
t.Fatalf("export must carry the tombstone, not the superseded value: %+v", entries[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUDLAllForUser_PagesOnACidBoundaryAndResumes(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
const uid = "zAlice"
|
|
||||||
cids := []string{"bagcsaaaaa1", "bagcsaaaaa2", "bagcsaaaaa3"}
|
|
||||||
for _, cid := range cids {
|
|
||||||
for _, key := range []string{"like", "seek"} {
|
|
||||||
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 100, "rec-"+cid+"-"+key, "", false, false); err != nil || !ok {
|
|
||||||
t.Fatalf("upsert: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A cid's keys are never split across a page, so a limit of 1 still yields
|
|
||||||
// both of the first cid's rows.
|
|
||||||
page1, next1, err := c.UDLAllForUser(uid, "", 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("page 1: %v", err)
|
|
||||||
}
|
|
||||||
if len(page1) != 2 || page1[0].Cid != cids[0] {
|
|
||||||
t.Fatalf("page 1 should hold both rows of %s, got %+v", cids[0], page1)
|
|
||||||
}
|
|
||||||
if next1 != cids[0] {
|
|
||||||
t.Fatalf("cursor should name the last cid emitted, want %q got %q", cids[0], next1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walking the cursor must visit every row exactly once.
|
|
||||||
all := append([]UDLUserAllEntry{}, page1...)
|
|
||||||
cursor := next1
|
|
||||||
for cursor != "" {
|
|
||||||
page, next, err := c.UDLAllForUser(uid, cursor, 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("page after %q: %v", cursor, err)
|
|
||||||
}
|
|
||||||
all = append(all, page...)
|
|
||||||
cursor = next
|
|
||||||
}
|
|
||||||
if len(all) != 6 {
|
|
||||||
t.Fatalf("want 6 rows across all pages, got %d: %+v", len(all), all)
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, e := range all {
|
|
||||||
k := e.Cid + "/" + e.Key
|
|
||||||
if seen[k] {
|
|
||||||
t.Fatalf("%s emitted twice across pages", k)
|
|
||||||
}
|
|
||||||
seen[k] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data written before the cids index existed must still export, or every
|
|
||||||
// profile that predates this feature silently recovers as empty.
|
|
||||||
func TestUDLBackfillUserCids_MakesLegacyDataExportable(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
const (
|
|
||||||
uid = "zAlice"
|
|
||||||
cid = "bagcsaaaaone"
|
|
||||||
key = "like"
|
|
||||||
)
|
|
||||||
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 100, "rec", "", false, false); err != nil || !ok {
|
|
||||||
t.Fatalf("upsert: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate the pre-index state: the cell and the other indexes exist, the
|
|
||||||
// cids set does not.
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := c.client.Del(ctx, c.buildKey(udlIdxUserCids(uid))).Err(); err != nil {
|
|
||||||
t.Fatalf("del cids index: %v", err)
|
|
||||||
}
|
|
||||||
if entries, _, err := c.UDLAllForUser(uid, "", 0); err != nil || len(entries) != 0 {
|
|
||||||
t.Fatalf("precondition: legacy data should export as empty, got %+v err=%v", entries, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
added, err := c.UDLBackfillUserCids()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("backfill: %v", err)
|
|
||||||
}
|
|
||||||
if added != 1 {
|
|
||||||
t.Fatalf("want 1 cid backfilled, got %d", added)
|
|
||||||
}
|
|
||||||
entries, _, err := c.UDLAllForUser(uid, "", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("export after backfill: %v", err)
|
|
||||||
}
|
|
||||||
if len(entries) != 1 || entries[0].Cid != cid || entries[0].Key != key {
|
|
||||||
t.Fatalf("backfilled data should export, got %+v", entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idempotent: a second run adds nothing.
|
|
||||||
if added, err := c.UDLBackfillUserCids(); err != nil || added != 0 {
|
|
||||||
t.Fatalf("second backfill should be a no-op, added=%d err=%v", added, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The backfill splits `<uid>:cid:<cid>` on the FIRST separator, because a uid
|
|
||||||
// is multibase base58btc and its alphabet has no colon. Splitting on the last
|
|
||||||
// occurrence — the obvious-looking choice — mis-parses a cid that contains
|
|
||||||
// ":cid:" in its own name into uid "zAlice:cid:weird", cid "name".
|
|
||||||
func TestUDLBackfillUserCids_SplitsOnTheFirstSeparator(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
const uid = "zAlice"
|
|
||||||
cid := "weird:cid:name"
|
|
||||||
if ok, err := c.UDLUpsertIfNewer(uid, cid, "like", 1, 100, "rec", "", false, false); err != nil || !ok {
|
|
||||||
t.Fatalf("upsert: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := c.client.Del(ctx, c.buildKey(udlIdxUserCids(uid))).Err(); err != nil {
|
|
||||||
t.Fatalf("del cids index: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := c.UDLBackfillUserCids(); err != nil {
|
|
||||||
t.Fatalf("backfill: %v", err)
|
|
||||||
}
|
|
||||||
entries, _, err := c.UDLAllForUser(uid, "", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("export: %v", err)
|
|
||||||
}
|
|
||||||
if len(entries) != 1 || entries[0].Cid != cid {
|
|
||||||
t.Fatalf("want the cid parsed whole, got %+v", entries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A tombstone must leave the cell in place but drop the cid from the user+key
|
|
||||||
// index.
|
|
||||||
//
|
|
||||||
// The cell has to stay: it *is* the CRDT state, and deleting it would let an
|
|
||||||
// older write resurrect the value the tombstone retracted. The index entry has
|
|
||||||
// to go: `active_for_user_key` returns every member of that set, so a set that
|
|
||||||
// only ever grows means each My List / Continue Watching read carries every
|
|
||||||
// title the user has ever un-liked or finished — with its full record blob —
|
|
||||||
// forever. The reader filters them out again, so the cost is invisible until
|
|
||||||
// the account is old.
|
|
||||||
func TestUDLUpsert_TombstonePrunesUserKeyIndexButKeepsCell(t *testing.T) {
|
|
||||||
c, _ := newTestClient(t, "")
|
|
||||||
|
|
||||||
const (
|
|
||||||
uid = "zAlice"
|
|
||||||
cid = "bagcsaaalocator"
|
|
||||||
key = "seek"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A live position: indexed, readable.
|
|
||||||
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 100, "rec-v1", "", false, false); err != nil || !ok {
|
|
||||||
t.Fatalf("live upsert: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
entries, err := c.UDLListUserKey(uid, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("active_for_user_key: %v", err)
|
|
||||||
}
|
|
||||||
if len(entries) != 1 || entries[0].Cid != cid {
|
|
||||||
t.Fatalf("live position should be indexed, got %+v", entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finishing it tombstones the seek.
|
|
||||||
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 2, 200, "rec-v2-tombstone", "", false, true); err != nil || !ok {
|
|
||||||
t.Fatalf("tombstone upsert: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err = c.UDLListUserKey(uid, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("active_for_user_key after tombstone: %v", err)
|
|
||||||
}
|
|
||||||
if len(entries) != 0 {
|
|
||||||
t.Fatalf("tombstoned cid must leave the user+key index, got %+v", entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ...but the cell itself survives, at the tombstone's version.
|
|
||||||
cell, exists, err := c.UDLGetCell(uid, cid, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get cell: %v", err)
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
t.Fatal("tombstone must not delete the cell — an older write could then resurrect the value")
|
|
||||||
}
|
|
||||||
if cell.Version != 2 || cell.Record != "rec-v2-tombstone" {
|
|
||||||
t.Fatalf("cell should hold the tombstone record at v2, got %+v", cell)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And the high-water mark still rejects the superseded write.
|
|
||||||
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 300, "rec-v1-replay", "", false, false); err != nil || ok {
|
|
||||||
t.Fatalf("stale replay must be rejected: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-watching re-establishes the cell and puts it back in the index.
|
|
||||||
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 3, 400, "rec-v3", "", false, false); err != nil || !ok {
|
|
||||||
t.Fatalf("re-establish upsert: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
entries, err = c.UDLListUserKey(uid, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("active_for_user_key after re-establish: %v", err)
|
|
||||||
}
|
|
||||||
if len(entries) != 1 || entries[0].Cid != cid {
|
|
||||||
t.Fatalf("re-established cid must return to the index, got %+v", entries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user