Compare commits

..
25 Commits
Author SHA1 Message Date
Worph fb4bdad61b v1.2.0: skip unchanged writes, transactional schema/root counters, snapshot replay for meta:events 0-0 (drops boot republish), collect-once search driver
Build and Push Docker Image / build (push) Successful in 7m38s
2026-10-19 21:41:55 +00:00
Worph dc5cbdfb9b replace Redis with an embedded SQLite string->string KV store (group commit, in-tx events, SQL search)
Build and Push Docker Image / build (push) Successful in 2m44s
2026-10-19 20:51:00 +00:00
Worph c53146e514 rank vectors: provider-file-tachiyomi
Build and Push Docker Image / build (push) Successful in 2m28s
2026-10-19 18:45:19 +00:00
Worph bb04a3f4dc release (first build on gitea-watch / IPCR)
Build and Push Docker Image / build (push) Successful in 1m0s
2026-10-13 11:02:23 +00:00
Worph 36eb3543b9 gitea workflow: push to IPCR's direct host (Cloudflare 100 MB cap) [skip ci] 2026-10-13 10:02:01 +00:00
Worph 155fd83392 gitea workflow: pull path via metadec.eth
Build and Push Docker Image / build (push) Successful in 56s
2026-10-12 18:30:11 +00:00
Worph cf63a8da68 gitea workflow: publish to IPCR (metadec.eth) instead of ghcr
Build and Push Docker Image / build (push) Successful in 55s
2026-10-12 18:26:08 +00:00
Worph 27ed45999b beacon v2 discovery: advertise metamesh.core + service resources on 239.255.99.1:9099, /api/neighbors ?cap= filter
Build and Push Docker Image / build (push) Failing after 14s
2026-10-04 11:51:03 +00:00
Worph 9254f043a4 README: sync with current code 2026-10-03 11:51:55 +00:00
Worph ebc894fa42 deleting a cids/ member also drops its reverse alias
Build and Push Docker Image / build (push) Failing after 14s
2026-09-16 17:10:00 +00:00
Worph 515ec04369 bugfix and dashboard improvement
Build and Push Docker Image / build (push) Failing after 13s
2026-09-14 19:04:23 +02:00
Worph 0403a6f3bc replace file discovery by udp discovery
Build and Push Docker Image / build (push) Failing after 13s
2026-09-14 11:24:46 +02:00
Worph 355c666183 chnage service discovery and identity 2026-09-13 19:48:52 +02:00
Worph 0cccceccd9 align with metadata standard
Build and Push Docker Image / build (push) Failing after 13s
2026-09-13 15:57:30 +02:00
Worph 40b553016c cid vector
Build and Push Docker Image / build (push) Failing after 13s
2026-09-13 14:36:50 +02:00
Worph 4894810fd4 new file tuple API
Build and Push Docker Image / build (push) Failing after 13s
2026-09-13 12:01:33 +02:00
Worph ac21315255 improved cid resolution
Build and Push Docker Image / build (push) Failing after 13s
2026-09-12 21:37:38 +02:00
Worph ec1b227631 improved search 2026-09-12 16:37:51 +02:00
Worph 8f4e08b6f1 rework domain field
Build and Push Docker Image / build (push) Failing after 13s
2026-09-02 00:07:02 +02:00
Worph 0c4e3a2227 align to metadata keys doc
Build and Push Docker Image / build (push) Failing after 13s
2026-08-30 19:52:36 +02:00
Worph 1f41ea0166 bugfix scroll
Build and Push Docker Image / build (push) Failing after 14s
2026-08-25 18:09:44 +02:00
Worph 5efdd2c07d improved identity management 2026-08-25 17:48:23 +02:00
Worph 6ae87a2cdb audio support widening 2026-08-21 18:23:20 +02:00
Worph 604e718cdc add identity management
Build and Push Docker Image / build (push) Failing after 13s
2026-08-06 11:03:50 +02:00
Worph 0bbc656263 add identity page
Build and Push Docker Image / build (push) Failing after 13s
2026-08-05 23:37:12 +02:00
100 changed files with 11206 additions and 5204 deletions
+87
View File
@@ -0,0 +1,87 @@
# 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}}
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 }}
+8 -9
View File
@@ -6,20 +6,20 @@
# - 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 (/meta-core/db/meta-core.db)
# - 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 +74,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 +104,7 @@ 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/services \ /meta-core/services \
/meta-core/mounts/errors \ /meta-core/mounts/errors \
/files \ /files \
@@ -140,13 +140,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 \
+211 -113
View File
@@ -1,39 +1,50 @@
# meta-core # meta-core
Standalone Go service that owns Redis, the leader-info file, the HTTP+SSE Standalone Go service that owns the metadata store (an embedded SQLite file),
metadata surface, and a WebDAV file server for the MetaMesh dev/prod stack. the HTTP+SSE metadata surface, the
User Data Layer store and signing keystore, and a WebDAV file server for the
MetaMesh dev/prod stack.
## Overview ## Overview
meta-core runs as its own container (`metacore-app`). Other MetaMesh services meta-core runs as its own container (`metacore-app`). Other MetaMesh services
(meta-sort, meta-fuse, meta-stremio, meta-dup, meta-share) discover it by (meta-sort, meta-fuse, meta-stremio, meta-dup, meta-search, meta-share,
reading `/meta-core/locks/kv-leader.info` and then call its HTTP API. Redis meta-gateway, meta-watch, …) locate it over UDP multicast (beacon v2 —
is not exposed outside the container — all metadata I/O is mediated by the its `metamesh.core` advertise carries the full `/urls` payload) and then call its
HTTP API. HTTP API. `META_CORE_URL` on a client pins it instead. The store is embedded
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)).
### Key capabilities ### Key capabilities
| Feature | Description | | Feature | Description |
|---------|-------------| |---------|-------------|
| **Leader gate** | Bash `flock` loop in `docker/leader-election.sh` only `exec`s supervisord on the winner. The Go binary therefore only ever runs as leader. | | **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, services, 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. |
| **SSE event streams** | `/api/events/files` and `/api/events/meta` mirror the underlying Redis Streams so external services never speak Redis. | | **Indexed search** | `POST /api/metadata/search` builds a superset of candidates in SQL — a partial expression index `kv_field` over (field name, value) of every record key, plus an FTS5 trigram table — 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 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** | File-based registry under `/meta-core/services/*.json` with heartbeat + dead-service cleanup. | | **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`. | | **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. |
| **Mount management** | rclone-only mount manager (SMB rendered into `:smb:`, plus pre-defined remotes). Read-only by construction. | | **Mount management** | rclone-only mount manager (SMB rendered into `:smb:`, plus pre-defined remotes). Read-only by construction. |
| **UUID-rooted storage** | Roots are UUIDv7 (Crockford Base32, ULID layout); CIDs are reverse-index aliases. Schema-version sentinel refuses to boot against stale data. | | **UUID-rooted storage** | Roots are UUIDv7 (Crockford Base32, ULID layout); CIDs are reverse-index aliases. Schema-version sentinel refuses to boot against stale data. |
| **Identity keystore** | Multi-account secp256k1 signing keys under `/meta-core/identity/accounts/`, created from the dashboard's Identity menu (never auto-generated). A private key is returned only by generate, or by reveal against a proof-of-possession signature (delete needs the same proof); `/api/identity/sign` refuses challenge-domain payloads. |
| **User Data Layer** | Version-gated store for per-user signed (and, for private-tier keys, encrypted) records — My List, Continue Watching, ratings — indexed by user and CID. meta-core never inspects the crypto. See [User Data Layer](../../docs/project-architecture/user-data-layer.md). |
| **Dashboard + editor** | nginx serves a React dashboard at `/` and the standalone metadata editor at `/editor/`. |
### Design characteristics ### Design characteristics
- **Single-leader** — flock on a shared volume; no external consensus. - **Single writer** — flock on the `/meta-core` volume; no external consensus.
- **HTTP-only externally** — Redis is bound to the container, never published. - **HTTP-only externally** — there is no database port; debug read-only with
`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
@@ -41,34 +52,31 @@ HTTP API.
┌──────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────┐
│ metacore-app (standalone container) │ │ metacore-app (standalone container) │
│ │ │ │
│ leader-election.sh (flock /meta-core/locks/kv-leader.lock, │
│ then exec supervisord) │
│ supervisord │ │ supervisord │
│ ├─ leader-election.sh (flock /meta-core/locks/kv-leader.lock, │ │ ├─ nginx :80 (dashboard, /editor, API proxy, │
│ │ writes kv-leader.info, exec supervisord) │ │ │ proxy_cache for /webdav, rclone GUI /rclone) │
│ ├─ redis-server (AOF + RDB on /meta-core/db/redis) │ │ ├─ rclone rcd (mount daemon, RC API on :5572) │
│ ├─ nginx (TLS termination, proxy_cache for /webdav) │
│ ├─ rclone rcd (mount daemon, RC API) │
│ ├─ 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 │
└──────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────┘
│ │
┌──────────────────────┴──────────────────────┐ ┌──────────────────────┴──────────────────────┐
│ /meta-core (shared volume) │ │ /meta-core (meta-core's own volume) │
│ locks/kv-leader.{lock,info} │ │ locks/kv-leader.lock │
│ db/redis/ │ │ db/meta-core.db (+ -wal, -shm) │
│ services/*.json │ │ identity/accounts/*.json │
│ mounts/{mounts.json, errors/} │ │ mounts/{mounts.json, errors/} │
│ watchers.json │ │ watchers.json │
│ cache/ (nginx proxy_cache) │ │ cache/ (nginx proxy_cache) │
└─────────────────────────────────────────────┘ └─────────────────────────────────────────────┘
▲
┌──────────────────────────────┼────────────────────────────────┐ meta-sort / meta-fuse / meta-stremio / meta-search / meta-share / …
│ │ │ │ 1. hear the metamesh.core advertise on UDP multicast (carries /urls)
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ └─ 2. HTTP + SSE (+ WebDAV) to metacore-app — never the store, never the volume
│ meta-sort │ │ meta-fuse │ │ meta-stremio│
│ │ │ │ │ │
│ reads │ HTTP+SSE │ reads │ HTTP+SSE │ reads │
│ leader.info├──────────────►│ leader.info├───────────────►│ leader.info│
└─────────────┘ └─────────────┘ └─────────────┘
``` ```
## Quick start ## Quick start
@@ -82,8 +90,9 @@ make test # run Go unit tests
``` ```
`make docker` tags the image as `:1.0.0`, `:latest`, **and** `:local`. The `make docker` tags the image as `:1.0.0`, `:latest`, **and** `:local`. The
`:local` tag is the one every sub-stack docker-compose.yml references `:local` tag is the one the sub-stack docker-compose files reference
(meta-share 10-peer federation, meta-gateway dev stacks); without it the (meta-share federation, meta-read and meta-listen dev stacks); the meta-gateway
dev stack (`docker-compose.gateway.yml`) uses `:latest`. Without these tags the
sub-stacks can't see a freshly-built image by name. sub-stacks can't see a freshly-built image by name.
> **Source-change → recreate.** `make docker` rebuilds and re-tags the > **Source-change → recreate.** `make docker` rebuilds and re-tags the
@@ -94,12 +103,14 @@ sub-stacks can't see a freshly-built image by name.
> >
> ```bash > ```bash
> make docker > make docker
> docker compose -f packages/meta-gateway/dev/docker-compose.yml \ > docker compose -f packages/meta-gateway/dev/docker-compose.gateway.yml \
> up -d --force-recreate metagateway-generic-core > up -d --force-recreate metagateway-hub-metacore
> docker compose -f packages/meta-gateway/dev/docker-compose.torznab.yml \
> up -d --force-recreate metagateway-torznab-core
> docker compose -f packages/meta-share/dev/docker-compose.yml \ > docker compose -f packages/meta-share/dev/docker-compose.yml \
> --profile share-10node up -d --force-recreate metashare-core metashare-core-test > --profile share-10node up -d --force-recreate metashare-core metashare-core-test
> docker compose -f packages/meta-read/dev/docker-compose.yml \
> up -d --force-recreate metaread-dev-core
> docker compose -f packages/meta-listen/dev/docker-compose.yml \
> up -d --force-recreate metalisten-dev-core
> ``` > ```
> >
> The symptom of a missed recreate is missing API fields on `/urls` > The symptom of a missed recreate is missing API fields on `/urls`
@@ -109,7 +120,7 @@ sub-stacks can't see a freshly-built image by name.
> >
> ```bash > ```bash
> docker inspect meta-core:local --format '{{.Id}}' # fresh SHA > docker inspect meta-core:local --format '{{.Id}}' # fresh SHA
> docker ps --filter ancestor=meta-core:local --format '{{.Names}}' > docker ps --filter ancestor=meta-core:local --format '{{.Names}}' # repeat for :latest
> # any container whose Image SHA doesn't match the fresh SHA needs --force-recreate. > # any container whose Image SHA doesn't match the fresh SHA needs --force-recreate.
> ``` > ```
@@ -126,36 +137,43 @@ HTTP port (no auth, bypasses Caddy) is `http://localhost:18083`.
## Configuration ## Configuration
Environment variables consumed by `internal/config/config.go`: Environment variables (from `internal/config/config.go` unless noted):
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `META_CORE_PATH` | `/meta-core` | Shared volume root (locks, db, services, mounts, cache). | | `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` | Identity in the service registry. | | `SERVICE_NAME` | `meta-core` | Name in the beacon v2 advertise. |
| `SERVICE_VERSION` | `1.0.0` | Reported via `/status` and registry. | | `SERVICE_VERSION` | `1.0.0` | Reported via `/status`. |
| `API_PORT` | `8180` | External port baked into the leader-info `baseUrl`. | | `API_PORT` | `8180` | External port baked into the constructed `baseUrl` when `BASE_URL` is unset. |
| `BASE_URL` | _empty_ | Overrides the constructed `baseUrl` if set (used for HTTPS perimeter). | | `BASE_URL` | _empty_ | Overrides the constructed `baseUrl` (the Caddy/HTTPS perimeter URL). |
| `REDIS_PORT` | `6379` | Local Redis port. | | `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_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. |
| `HEALTH_CHECK_INTERVAL_MS` | `5000` | Internal health-loop cadence. | | `ENABLE_FILE_WATCHER` | `true` | Disable to suppress the in-process watcher entirely (`POST /api/files/register` stays available). |
| `HEARTBEAT_INTERVAL_MS` | `30000` | Service registry heartbeat. | | `ENABLE_UDP_DISCOVERY` | `true` | beacon v2 advertise + listen. |
| `STALE_THRESHOLD_MS` | `60000` | Age past which a registry entry is marked stale. | | `BEACON_GROUP` / `BEACON_PORT` | `239.255.99.1` / `9099` | Multicast group and UDP port. |
| `CLEANUP_INTERVAL_MS` | `600000` | Dead-service cleaner cadence. | | `BEACON_INTERVAL_MS` | `10000` | Unsolicited advertise interval. |
| `DEAD_SERVICE_THRESHOLD_MS` | `600000` | Age past which a stale entry is deleted. | | `ELECTION_RETRY_SECS` | `5` | flock retry interval (read by `docker/leader-election.sh`, not the Go binary). |
| `WATCH_INTERVAL_MS` | `1000` | Watcher poll cadence. |
| `DEBOUNCE_MS` | `30000` | File-event debounce window. |
| `ENABLE_FILE_WATCHER` | `true` | Disable to suppress the in-process watcher entirely. |
Note: there is no `META_CORE_REDIS_PORT`, no `META_CORE_SERVICE_NAME`, and `HEALTH_CHECK_INTERVAL_MS`, `HEARTBEAT_INTERVAL_MS`, `STALE_THRESHOLD_MS`,
no `META_CORE_LEADER_TIMEOUT`. The Redis URL is discovered locally; service `CLEANUP_INTERVAL_MS`, `DEAD_SERVICE_THRESHOLD_MS`, `WATCH_INTERVAL_MS` and
identity uses `SERVICE_NAME`; leader timeout is governed by the bash flock `DEBOUNCE_MS` are still parsed by `internal/config/config.go` but nothing reads
retry interval (`ELECTION_RETRY_SECS`, default `5`). them (leftovers of the file-based registry); setting them has no effect. There
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
configs created via `/api/watchers`.
## API reference ## API reference
All endpoints below are served by `internal/api/server.go`. Examples assume All endpoints below are registered in `internal/api/server.go` (mounts,
watcher and watchers routes in their own packages' `handlers.go`). Examples assume
the in-container Go port `:9000`; from the host use the in-container Go port `:9000`; from the host use
`https://metacore-dev.localhost:8083` (Caddy) or `http://localhost:18083` `https://metacore-dev.localhost:8083` (Caddy) or `http://localhost:18083`
(debug-direct). (debug-direct).
@@ -163,16 +181,47 @@ 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 /services # registered services + heartbeats GET /neighbors # neighbours heard over UDP (beacon v2)
GET /api/services # alias for /services GET /api/neighbors # same, under /api
GET /services/{name} # one service GET /api/stats # dashboard counters (see below)
GET /services/cleanup/stats
``` ```
`/services`, `/api/services`, `/services/{name}` and `/services/cleanup/stats`
are **gone** — the registration-and-heartbeat registry they served was replaced
by beacon v2 (`/neighbors`). nginx answers an unknown non-`/api` path
with the dashboard's `index.html`, so a stale caller gets `200 text/html` rather
than a 404; that is what made the dashboard fail with
`SyntaxError: Unexpected token '<'` until 1.0.21.
#### `GET /api/stats`
Every headline counter in one request, split by cost:
```jsonc
{
"records": 88709, // count of idx:roots/* — metadata roots, file or not
"redisKeys": 3115447, // kv row count (name kept for compatibility; cached 60 s)
"redisMemory": "588.74M",// DB + WAL file size (name kept for compatibility)
"identities": 3, // signing accounts on disk
"files": { "count": 4864, "totalSize": 64587914424 }, // null until first sweep
"udlUsers": 5, // accounts holding User Data Layer records
"sweptAt": 1789402401970,
"sweeping": false
}
```
`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
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
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`
across records that share a file.
### Metadata — primary surface ### Metadata — primary surface
These are the routes other services use. `{hash}` may be a UUID root or These are the routes other services use. `{hash}` may be a UUID root or
@@ -198,6 +247,8 @@ GET /api/meta/{cid} # resolve CID → metadata document
GET /api/file/{cid}/info # CID resolution metadata GET /api/file/{cid}/info # CID resolution metadata
GET /file/{cid} # serve file bytes (range-aware) GET /file/{cid} # serve file bytes (range-aware)
POST /file/cid # compute CID for an uploaded file POST /file/cid # compute CID for an uploaded file
POST /api/files/register # mint + alias one file under /files/plugin by midhash
# (the watcher's path for one file; works with the watcher OFF)
HEAD /data/{hash} # existence + size HEAD /data/{hash} # existence + size
GET /data/{hash}/path # absolute path on /files GET /data/{hash}/path # absolute path on /files
``` ```
@@ -209,6 +260,7 @@ the `/file/{cid}` reverse-lookup path.)
```bash ```bash
GET /api/metadata/hash-ids GET /api/metadata/hash-ids
GET /api/files/tuples
GET /api/metadata/list GET /api/metadata/list
POST /api/metadata/search POST /api/metadata/search
POST /api/metadata/batch POST /api/metadata/batch
@@ -221,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...}
@@ -239,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
@@ -248,6 +301,44 @@ signalling).
```bash ```bash
GET /api/events/files # file:events stream (watcher events) GET /api/events/files # file:events stream (watcher events)
GET /api/events/meta # meta:events stream (metadata mutations) GET /api/events/meta # meta:events stream (metadata mutations)
GET /api/events/poll # DEPRECATED long-poll over file:events — use SSE
```
### Identity (signing keystore)
Reached through meta-search's identity proxy (and directly by meta-gateway,
meta-read, meta-listen) for record signing, UDL writes and keypair sign-in.
```bash
GET /api/identity # status (never the private key)
GET /api/identity/accounts
POST /api/identity/generate # the only call that returns a new private key
POST /api/identity/import
POST /api/identity/challenge # one-shot challenge for proof-of-possession
POST /api/identity/reveal # needs proof-of-possession
DELETE /api/identity # needs proof-of-possession; reports purged UDL counts
POST /api/identity/sign
POST /api/identity/sign-batch
GET /api/identity/aead-key
```
### User Data Layer
Records arrive signed (private-tier ones encrypted) as opaque base64 CBOR;
writes are version-gated (stale → `409 stale_version`, or `accepted:false` per
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
GET /api/udl/record?uid&cid&key
PUT /api/udl/record
PUT /api/udl/records # batch
GET /api/udl/user/{uid}/key/{key}
GET /api/udl/user/{uid}/cid/{cid}
GET /api/udl/user/{uid}/all
GET /api/udl/user/{uid}/count
GET /api/udl/cid/{cid}/users
GET /api/udl/cid/{cid}/aggregate # meta-watch Aggregate wire shape
GET /api/udl/users/stats
``` ```
### Mounts ### Mounts
@@ -283,42 +374,43 @@ 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
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.
## Leader gate ## DB-file mutex
Leader election is a bash flock loop in `docker/leader-election.sh`, not a `docker/leader-election.sh` is a bash flock loop, not a Go-level state
Go-level state machine. When `flock` succeeds on `kv-leader.lock`, the machine. When `flock` succeeds on `/meta-core/locks/kv-leader.lock`, the
script writes the leader API URL to `kv-leader.info` and `exec`s script `exec`s supervisord; a second container mounting the same volume
supervisord. When the process tree dies, the kernel releases the lock and retries every `ELECTION_RETRY_SECS` and stays dormant. When the process tree
any standby instance can take over. There is no follower path inside the dies, the kernel releases the lock. There is no follower path inside the Go
Go binary — there is no `OnBecomeLeader` / `OnBecomeFollower` callback API. binary.
`kv-leader.info` is a single plaintext line containing the API base URL: The lock is a **mutex, not service discovery** — it only stops two
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
http://10.0.1.50:9000 (see [beacon-v2.md](../../docs/project-architecture/beacon-v2.md)).
``` `internal/leader` keeps its name for history, but only builds the `/urls`
payload (`baseUrl`, `apiUrl`, `webdavUrl`, `webdavUrlInternal`) that `/urls`
Other services read this file, then call `GET /urls` for full discovery. and the UDP announce share.
The dev compose runs a single `metacore-app`; the flock primitive is
retained as a correctness anchor (one process owns Redis and the
writable state under `/meta-core`), not as live failover.
## 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 for a file is a of the `kv` table at `file:<uuid>/<property>`; every known CID is a key-set member
reverse-index entry `cid:<algorithm>:<value> → <uuid>`. Full details in `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
[`docs/metadata-storage-structure.md`](docs/metadata-storage-structure.md) [`docs/metadata-storage-structure.md`](docs/metadata-storage-structure.md)
and design rationale in [`docs/uuid-rooted-metadata.md`](docs/uuid-rooted-metadata.md). and design rationale in [`docs/uuid-rooted-metadata.md`](docs/uuid-rooted-metadata.md).
The authoritative registry of field semantics and value formats lives in The authoritative registry of field semantics and value formats lives in
@@ -328,15 +420,16 @@ the repo-root [`METADATA_KEYS.md`](../../METADATA_KEYS.md).
| Package | Purpose | | Package | Purpose |
|---------|---------| |---------|---------|
| `cmd/meta-core` | Entry point; Redis connect + schema sentinel + 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 leader info from local hostname/IP/config; no election logic). | | `internal/leader` | `LeaderInfoProvider` — builds the `/urls` payload from hostname/IP/config; no election logic. |
| `internal/storage` | Redis wrapper (`client.go`), UUIDv7 minting (`uuid.go`), CID reverse index (`cid_resolution.go`), schema sentinel (`schema_version.go`), dual-root migration (`dual_root_migration.go`). | | `internal/meshdisco` | beacon v2 node (advertise, probe, neighbour map) — the Go port of the spec. |
| `internal/cid` | CID parsing + algorithm ranking for canonical-CID selection. | | `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/discovery` | Service registry + dead-service cleaner. | | `internal/storage` | Flat-key records (`client.go`), UUIDv7 minting, CID reverse index (`cid_resolution.go`), schema sentinel, search over `kv_field` + FTS (`search.go`), UDL store (`udl.go`, `udl_admin.go`), stats. |
| `internal/events` | Keyspace-notification → `meta:events` stream publisher (`meta_publisher.go`). | | `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/api` | HTTP server + all handlers + SSE event endpoints. | | `internal/identity` | Multi-account secp256k1 keystore, challenge/proof-of-possession, signature verification. |
| `internal/schema` | Live per-field schema indexer (consumes `meta:events`). | | `internal/api` | HTTP server + all handlers + SSE event endpoints + search matchers + `/api/stats`. |
| `internal/schema` | Per-field schema, folded from counters the write path keeps exact (`idx:schema/…`). |
| `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). |
@@ -348,13 +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 service discovery + dead-service cleaner 5. Start beacon v2 node (unless ENABLE_UDP_DISCOVERY=false)
6. Construct API server (initialises watcher, mounts, WebDAV) 6. Construct API server (identity keystore migration, watcher, watchers,
7. Start API server; on first connect, start MetaPublisher + schema Indexer mounts, WebDAV)
and republish existing metadata onto meta:events 7. EnsureCounters — one-time build of the derived counters (record count,
8. Wait for SIGINT/SIGTERM, shutdown in reverse order per-field schema) on a store that predates them; blocking, ~1 min at 169k
records, then never again
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
@@ -362,13 +459,14 @@ 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 |
| `golang.org/x/net` | WebDAV support | | `golang.org/x/net` | WebDAV support |
| Go stdlib | context, net/http, os, sync, syscall |
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. rclone, nginx, and supervisord alongside the binary, plus the React dashboard
and metadata editor (built from `dashboard/` and `editor/` in the Dockerfile).
## License ## License
+83 -38
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -10,8 +11,8 @@ import (
"github.com/metazla/meta-core/internal/api" "github.com/metazla/meta-core/internal/api"
"github.com/metazla/meta-core/internal/config" "github.com/metazla/meta-core/internal/config"
"github.com/metazla/meta-core/internal/discovery"
"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/storage" "github.com/metazla/meta-core/internal/storage"
) )
@@ -29,51 +30,83 @@ 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 metadata store: an embedded SQLite file (internal/kv). There
// Retry connection since Redis may still be loading its dataset // is no separate database process to wait for.
redisURL := fmt.Sprintf("redis://localhost:%d", cfg.RedisPort) storageClient := storage.NewClient()
log.Printf("[meta-core] Connecting to Redis at %s", redisURL) if err := storageClient.Connect(cfg.DBPath()); err != nil {
log.Fatalf("[meta-core] Failed to open the metadata store: %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 {
log.Fatalf("[meta-core] Failed to connect to Redis after %d attempts: %v", maxRetries, err)
}
log.Println("[meta-core] Connected to Redis")
// Schema-version sentinel: alpha clean-wipe gate. Refuses to boot when // Schema-version sentinel: alpha clean-wipe gate. Refuses to boot when
// the existing Redis store predates the UUID-rooted schema, so legacy // the existing store predates the UUID-rooted schema, so legacy data
// data can't silently leak into the new code paths. See // can't silently leak into the new code paths. See
// docs/uuid-rooted-metadata.md. // 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)
// Create and start service discovery // Derived counters (record count, per-field schema) are kept exact by the
disc := discovery.NewService(cfg) // write path. A store that predates them gets a one-time build here,
if err := disc.Start(); err != nil { // before anything else writes.
log.Fatalf("[meta-core] Failed to start service discovery: %v", err) 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))
} }
// Create and start dead service cleaner // Beacon v2 (docs/project-architecture/beacon-v2.md): advertise meta-core
cleaner := discovery.NewCleaner(cfg) // as metamesh.core — carrying the /urls block services locate us by — plus
if err := cleaner.Start(); err != nil { // its own metamesh.service/<name> nav entry. The payload is rebuilt from
log.Printf("[meta-core] Warning: failed to start service cleaner: %v", err) // leaderProvider on every tick, which keeps it and GET /urls from drifting.
var mesh *meshdisco.Node
if cfg.EnableUDPDiscovery {
hostname, _ := os.Hostname()
mesh = meshdisco.New(meshdisco.Config{
Name: cfg.ServiceName,
Instance: hostname,
Version: Version,
Group: cfg.DiscoveryGroup,
Port: cfg.DiscoveryPort,
Interval: time.Duration(cfg.DiscoveryIntervalMS) * time.Millisecond,
Payload: func() (string, []meshdisco.Resource) {
service := meshdisco.Resource{
ID: "service",
Caps: []string{meshdisco.CapServicePrefix + cfg.ServiceName},
Endpoints: map[string]string{"ui": coreBaseURL(cfg)},
}
info := leaderProvider.LeaderInfo()
if info == nil {
return "starting", []meshdisco.Resource{service}
}
data, _ := json.Marshal(map[string]meshdisco.URLs{"urls": {
Hostname: info.Hostname,
BaseUrl: info.BaseUrl,
ApiUrl: info.ApiUrl,
WebdavUrl: info.WebdavUrl,
WebdavUrlInternal: info.WebdavUrlInternal,
}})
core := meshdisco.Resource{
ID: "core",
Caps: []string{meshdisco.CapCore},
Endpoints: map[string]string{"http": info.ApiUrl},
Data: data,
}
return "running", []meshdisco.Resource{core, service}
},
})
if err := mesh.Start(); err != nil {
log.Printf("[meta-core] Warning: UDP discovery disabled: %v", err)
mesh = nil
}
} else {
log.Println("[meta-core] UDP discovery disabled by ENABLE_UDP_DISCOVERY")
} }
// Create and start API server // Create and start API server
apiServer := api.NewServer(cfg, leaderProvider, disc, cleaner, storageClient) apiServer := api.NewServer(cfg, leaderProvider, storageClient)
apiServer.SetMeshNode(mesh)
if err := apiServer.Start(); err != nil { if err := apiServer.Start(); err != nil {
log.Fatalf("[meta-core] Failed to start API server: %v", err) log.Fatalf("[meta-core] Failed to start API server: %v", err)
} }
@@ -90,12 +123,10 @@ func main() {
log.Printf("[meta-core] Error stopping API server: %v", err) log.Printf("[meta-core] Error stopping API server: %v", err)
} }
if err := cleaner.Stop(); err != nil { if mesh != nil {
log.Printf("[meta-core] Error stopping service cleaner: %v", err) if err := mesh.Stop(); err != nil {
log.Printf("[meta-core] Error stopping UDP discovery: %v", err)
} }
if err := disc.Stop(); err != nil {
log.Printf("[meta-core] Error stopping service discovery: %v", err)
} }
if err := storageClient.Close(); err != nil { if err := storageClient.Close(); err != nil {
@@ -112,3 +143,17 @@ func waitForShutdown() {
<-sigChan <-sigChan
} }
// coreBaseURL is the browser-facing URL announced for the nav menu. It keeps
// the three-way fallback the file registry used (discovery/service.go):
// META_CORE_PUBLIC_URL wins (the reachable URL when there is no Caddy
// perimeter, e.g. a debug-direct port), then BASE_URL (the Caddy URL), then
// the container IP.
func coreBaseURL(cfg *config.Config) string {
if cfg.PublicURL != "" {
return cfg.PublicURL
}
if cfg.BaseURL != "" {
return cfg.BaseURL
}
return fmt.Sprintf("http://%s:%d", meshdisco.LocalIPv4(), cfg.APIPort)
}
+1 -1
View File
@@ -21,7 +21,7 @@
min-height: 100vh; min-height: 100vh;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-Cbd1uH5A.js"></script> <script type="module" crossorigin src="/assets/index-Bgo6moAc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DtR_2V4s.css"> <link rel="stylesheet" crossorigin href="/assets/index-DtR_2V4s.css">
</head> </head>
<body> <body>
+10
View File
@@ -8,6 +8,7 @@
"name": "@meta-core/dashboard", "name": "@meta-core/dashboard",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@noble/secp256k1": "^3.1.0",
"iso-639-3": "^3.0.1", "iso-639-3": "^3.0.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -744,6 +745,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@noble/secp256k1": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.1.0.tgz",
"integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@remix-run/router": { "node_modules/@remix-run/router": {
"version": "1.23.2", "version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+3 -2
View File
@@ -9,10 +9,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@noble/secp256k1": "^3.1.0",
"iso-639-3": "^3.0.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0"
"iso-639-3": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.38", "@types/react": "^18.2.38",
+37 -139
View File
@@ -1,155 +1,53 @@
import { useState, useEffect } from 'react'; import { useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- plain ES module, no .d.ts; imported for its side effect
// of defining the custom element.
import '../meta-service-menu.js';
interface ServiceInfo { /**
name: string; * ServiceNav — the neighbouring meta-* services, as a burger menu.
hostname: string; *
baseUrl: string; * Since meta-discovery v1 this is a thin wrapper around <meta-service-menu>,
status: string; * a framework-free custom element shared by every meta-* UI (see
lastHeartbeat: string; * scripts/check-mirrors.sh). The old implementation was ~165 lines of React
* that existed in four near-identical copies and polled meta-core's
* /api/services; the element polls this service's own /api/neighbors instead,
* so the nav still renders when meta-core is down.
*
* The element lives in shadow DOM and cannot see this app's CSS variables, so
* the four --mm-nav-* tokens below map our palette onto it.
*/
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace JSX {
interface IntrinsicElements {
'meta-service-menu': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
> & { current?: string; endpoint?: string; label?: string };
} }
interface ServicesResponse {
services: ServiceInfo[];
current: string;
} }
const serviceIcons: Record<string, string> = {
'meta-core': '🗄️',
'meta-sort': '📁',
'meta-fuse': '🗂️',
'meta-stremio': '🎬',
'meta-orbit': '🌐',
'default': '📦'
};
function formatServiceName(name: string): string {
return name.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
} }
function ServiceNav() { function ServiceNav() {
const [services, setServices] = useState<ServiceInfo[]>([]);
const [loading, setLoading] = useState(true);
const currentService = 'meta-core';
useEffect(() => { useEffect(() => {
const fetchServices = async () => { // Import above is for the side effect of defining the element; this
try { // keeps bundlers from tree-shaking it away in production builds.
const response = await fetch('/services');
if (response.ok) {
const data: ServicesResponse = await response.json();
setServices(data.services || []);
}
} catch (error) {
console.error('Failed to fetch services:', error);
} finally {
setLoading(false);
}
};
fetchServices();
const interval = setInterval(fetchServices, 30000);
return () => clearInterval(interval);
}, []); }, []);
if (loading) return null;
const sortedServices = [...services].sort((a, b) => a.name.localeCompare(b.name));
if (sortedServices.length === 0) return null;
return ( return (
<nav className="services-nav"> <>
<span className="services-nav-label">Services:</span> <meta-service-menu current="meta-core" />
<div className="services-nav-items">
{sortedServices.map(service => {
const icon = serviceIcons[service.name] || serviceIcons.default;
const isActive = service.name === currentService;
return (
<a
key={service.name}
href={isActive ? '#' : service.baseUrl}
className={`service-link${isActive ? ' active' : ''}`}
onClick={(e) => {
e.preventDefault();
if (!isActive) {
window.location.href = service.baseUrl;
}
}}
>
<span className="service-icon">{icon}</span>
<span>{formatServiceName(service.name)}</span>
<span className="service-status"></span>
</a>
);
})}
</div>
<style>{` <style>{`
.services-nav { meta-service-menu {
display: flex; --mm-nav-fg: var(--text-primary, #e0e0e0);
align-items: center; --mm-nav-bg: var(--bg-tertiary, var(--bg-secondary, #1a1a2e));
gap: 0.5rem; --mm-nav-border: var(--border-color, rgba(255,255,255,0.14));
} --mm-nav-accent: var(--accent-primary, #4ecdc4);
.services-nav-label {
color: #888;
font-size: 0.8rem;
white-space: nowrap;
}
.services-nav-items {
display: flex;
gap: 0.35rem;
}
.service-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
border-radius: 6px;
text-decoration: none;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s;
color: #ccc;
background: #0f3460;
border: 1px solid #1a4a7a;
}
.service-link:hover {
background: #1a4a7a;
border-color: rgba(78, 205, 196, 0.5);
text-decoration: none;
color: #fff;
}
.service-link.active {
background: linear-gradient(135deg, rgba(78, 205, 196, 0.2), rgba(68, 160, 141, 0.2));
border-color: rgba(78, 205, 196, 0.5);
color: #fff;
}
.service-link .service-icon {
font-size: 0.9rem;
}
.service-link .service-status {
width: 5px;
height: 5px;
border-radius: 50%;
background: #4ecdc4;
}
@media (max-width: 800px) {
.services-nav-label {
display: none;
}
} }
`}</style> `}</style>
</nav> </>
); );
} }
+28
View File
@@ -0,0 +1,28 @@
// JSON fetch that fails with a sentence instead of a parser error.
//
// nginx serves the dashboard with `try_files $uri $uri/ /index.html`, so ANY
// path it does not have a proxy rule for answers 200 text/html with the SPA
// shell. A caller that does `res.ok && res.json()` on such a path gets
// `SyntaxError: Unexpected token '<', "<!doctype "... is not valid JSON` and
// no clue which request produced it — which is exactly how a route deleted
// from the Go router (`/services`, removed in favour of /api/neighbors) sat
// broken on the dashboard.
//
// So: check the content type, and name the URL in every error.
export async function getJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
const ct = res.headers.get('content-type') || '';
if (!res.ok) {
throw new Error(`${url} → HTTP ${res.status}`);
}
if (!ct.includes('application/json')) {
// The SPA fallback is the overwhelmingly likely cause; say so.
throw new Error(
`${url} → ${res.status} ${ct || 'no content-type'} (not JSON — the route is ` +
`missing and nginx served the dashboard shell instead)`
);
}
return (await res.json()) as T;
}
+127
View File
@@ -0,0 +1,127 @@
// Client-side proof of possession for account operations.
//
// meta-core stores every private key in plaintext, so it can sign as anybody —
// which means a signature it produced proves nothing about who asked. The only
// signature that carries authority is one made where the key actually is, and
// for a browser-driven admin console that is here. meta-core enforces the other
// half by refusing to sign anything in the challenge domain
// (identity.ChallengeDomain), so this path cannot be short-circuited.
//
// The secret key entered on this page is used for exactly one signature and is
// never sent anywhere: `/api/identity/challenge` receives only the uid, and the
// request that follows carries only the challenge and its signature.
//
// Same scheme, same curve and same encodings as meta-watch's sign-in
// (packages/meta-watch/ui/js/auth.js) — deliberately, so one implementation on
// the server verifies both.
import * as secp from '@noble/secp256k1';
export type IdentityAction = 'reveal' | 'delete';
function hexToBytes(hex: string): Uint8Array | null {
let h = hex.trim();
if (h.startsWith('0x') || h.startsWith('0X')) h = h.slice(2);
if (h.length !== 64 || /[^0-9a-fA-F]/.test(h)) return null;
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
return out;
}
function bytesToBase64(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
/// base58btc, the multibase "z" alphabet. Spelled out rather than pulled in:
/// the alternative is a second dependency for thirty lines of arithmetic.
function base58Encode(bytes: Uint8Array): string {
const digits: number[] = [0];
for (const byte of bytes) {
let carry = byte;
for (let i = 0; i < digits.length; i++) {
carry += digits[i] << 8;
digits[i] = carry % 58;
carry = (carry / 58) | 0;
}
while (carry) {
digits.push(carry % 58);
carry = (carry / 58) | 0;
}
}
// Leading zero bytes encode as leading '1's rather than being dropped.
let out = '';
for (let i = 0; i < bytes.length && bytes[i] === 0; i++) out += B58[0];
for (let i = digits.length - 1; i >= 0; i--) out += B58[digits[i]];
return out;
}
/// The uid a secret key belongs to: "z" + base58btc(33-byte compressed pubkey).
/// Derived locally so a mistyped or wrong-account key is caught before any
/// request goes out. Mirrors `identityFromPrivKey` in meta-core's identity.go.
export function uidFromSecretKey(secretKeyHex: string): string | null {
const raw = hexToBytes(secretKeyHex);
if (!raw) return null;
try {
return 'z' + base58Encode(secp.getPublicKey(raw, true));
} catch {
return null; // not a valid scalar
}
}
/// Whether this page can sign at all.
///
/// noble's signAsync takes its sha256 from crypto.subtle, which browsers expose
/// only in a secure context. The dashboard behind Caddy is https and fine; the
/// debug-direct port (18083) is plain http, where every signature would fail
/// with a cryptic error unless we say so first.
export function canSign(): boolean {
return Boolean(globalThis.crypto?.subtle);
}
export const INSECURE_CONTEXT_MESSAGE =
'This page cannot sign over a plain-http connection — browsers only expose the ' +
'needed crypto on https (or localhost). Reach the dashboard through Caddy ' +
'(https://metacore-dev.localhost:8083) rather than the debug-direct port.';
/// Ask meta-core for a challenge, sign it locally, and hand back the proof.
///
/// Throws with a message fit to show the user. The caller passes the proof
/// straight into the reveal/delete request body.
export async function proveOwnership(
uid: string,
action: IdentityAction,
secretKeyHex: string
): Promise<{ challenge: string; signature: string }> {
if (!canSign()) throw new Error(INSECURE_CONTEXT_MESSAGE);
const raw = hexToBytes(secretKeyHex);
if (!raw) {
throw new Error('That is not a valid secret key: expected 64 hex characters.');
}
const derived = uidFromSecretKey(secretKeyHex);
if (!derived) throw new Error('That is not a valid secp256k1 secret key.');
if (derived !== uid) {
// Caught here rather than by the server, so the message can say which
// account the key actually is — the useful half of the answer.
throw new Error(`That key belongs to a different account (${derived}), not this one.`);
}
const cRes = await fetch('/api/identity/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid, action }),
});
const cBody = await cRes.json().catch(() => ({}));
if (!cRes.ok || !cBody.challenge) {
throw new Error(cBody.message ?? `Could not start verification (HTTP ${cRes.status}).`);
}
// signAsync prehashes with sha256 and returns a 64-byte compact (r‖s)
// signature — noble v3 dropped DER. meta-core's identity.Verify accepts both.
const sig = await secp.signAsync(new TextEncoder().encode(cBody.challenge), raw);
return { challenge: cBody.challenge, signature: bytesToBase64(sig) };
}
+308
View File
@@ -0,0 +1,308 @@
/**
* <meta-service-menu> — the neighbouring meta-* services, as a burger menu.
*
* Replaces the eight divergent "services bar" implementations (four copies of
* a React `ServiceNav.tsx`, two mirrored `services.js`, two bespoke inline
* ones) with a single framework-free custom element. It drops into the React
* dashboards as a plain tag — no React dependency — and into the vanilla UIs
* as an ES module.
*
* Data comes from the host service's own `GET /api/neighbors`, same-origin.
* Never cross-origin: the dev stack's self-signed certs and the OIDC redirects
* make a fetch to a neighbour a dead end. Links are plain navigations.
*
* ⚠ MIRRORED FILE. Byte-identical copies live in every meta-* UI tree; see
* `scripts/check-mirrors.sh`. Edit one → edit all.
*
* Shadow DOM is not optional here. The eight UIs use four incompatible token
* vocabularies, and two of them define *different colours under the same
* names* (`--bg-secondary` is #12121a in meta-sort, #16213e in meta-core,
* #161b22 in meta-dup). meta-search and meta-share are also the only
* light-mode-capable UIs, so a hardcoded dark palette would break them.
* The element therefore styles itself from four tokens with dark defaults:
*
* --mm-nav-fg, --mm-nav-bg, --mm-nav-border, --mm-nav-accent
*
* Each host maps its own variables onto those, e.g.
* meta-service-menu { --mm-nav-bg: var(--bg-secondary); }
*
* Attributes:
* current name of the host service, so it can be marked and not linked
* endpoint override the default "/api/neighbors"
* label button tooltip (default "Services")
*
* Property:
* rewriteUrl optional (name, url, neighbour) => url, for hosts that must
* translate internal hostnames into browser-reachable ones
* (meta-gateway does this for its dev/prod split).
*/
const ICONS = {
"meta-core": "⚙️",
"meta-sort": "📁",
"meta-fuse": "🗂️",
"meta-stremio": "🎬",
"meta-dup": "🔍",
"meta-search": "🔎",
"meta-share": "🔗",
"meta-gateway": "🌉",
"meta-watch": "📺",
"meta-listen": "🎧",
"meta-read": "📖",
};
const DEFAULT_ICON = "📦";
const POLL_MS = 30000;
function titleCase(name) {
return String(name || "")
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
const TEMPLATE = `
<style>
:host {
--_fg: var(--mm-nav-fg, #e8eaed);
--_bg: var(--mm-nav-bg, #1a1a2e);
--_border: var(--mm-nav-border, rgba(255, 255, 255, 0.14));
--_accent: var(--mm-nav-accent, #4ecdc4);
position: relative;
display: inline-block;
font-family: inherit;
font-size: 14px;
line-height: 1.4;
}
button.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
width: 2.1rem;
height: 2.1rem;
padding: 0;
cursor: pointer;
color: var(--_fg);
background: transparent;
border: 1px solid var(--_border);
border-radius: 8px;
font: inherit;
transition: border-color 0.15s, background 0.15s;
}
button.trigger:hover { border-color: var(--_accent); }
button.trigger:focus-visible {
outline: 2px solid var(--_accent);
outline-offset: 2px;
}
button.trigger[aria-expanded="true"] { border-color: var(--_accent); }
.bars { display: block; width: 15px; }
.bars span {
display: block;
height: 2px;
margin: 3px 0;
border-radius: 2px;
background: currentColor;
}
.panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 1000;
min-width: 13rem;
max-height: 70vh;
overflow-y: auto;
padding: 0.3rem;
background: var(--_bg);
border: 1px solid var(--_border);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.panel[hidden] { display: none; }
a.item, .item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.6rem;
border-radius: 6px;
color: var(--_fg);
text-decoration: none;
white-space: nowrap;
}
a.item:hover { background: color-mix(in srgb, var(--_accent) 18%, transparent); }
.item.active { color: var(--_accent); font-weight: 600; cursor: default; }
.item.dead { opacity: 0.55; cursor: not-allowed; }
.icon { font-size: 1rem; line-height: 1; }
.dot {
width: 6px;
height: 6px;
margin-left: auto;
border-radius: 50%;
background: var(--_accent);
}
.empty {
padding: 0.6rem;
opacity: 0.7;
font-size: 0.85em;
white-space: nowrap;
}
/* Respect a host that opts out of motion. */
@media (prefers-reduced-motion: reduce) {
button.trigger { transition: none; }
}
</style>
<button class="trigger" type="button" aria-haspopup="menu" aria-expanded="false">
<span class="bars" aria-hidden="true"><span></span><span></span><span></span></span>
</button>
<div class="panel" role="menu" hidden></div>
`;
class MetaServiceMenu extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = TEMPLATE;
this._btn = this.shadowRoot.querySelector("button.trigger");
this._panel = this.shadowRoot.querySelector(".panel");
this._neighbors = [];
this._timer = null;
this._open = false;
this._onDocClick = (e) => {
if (!this._open) return;
if (e.composedPath().includes(this)) return;
this._setOpen(false);
};
this._onKey = (e) => {
if (e.key === "Escape" && this._open) {
this._setOpen(false);
this._btn.focus();
}
};
}
connectedCallback() {
this._btn.title = this.getAttribute("label") || "Services";
this._btn.setAttribute("aria-label", this._btn.title);
this._btn.addEventListener("click", () => this._setOpen(!this._open));
document.addEventListener("click", this._onDocClick);
document.addEventListener("keydown", this._onKey);
this.refresh();
this._timer = setInterval(() => this.refresh(), POLL_MS);
}
disconnectedCallback() {
if (this._timer) clearInterval(this._timer);
this._timer = null;
document.removeEventListener("click", this._onDocClick);
document.removeEventListener("keydown", this._onKey);
}
get endpoint() {
return this.getAttribute("endpoint") || "/api/neighbors";
}
get current() {
return this.getAttribute("current") || "";
}
async refresh() {
try {
const res = await fetch(this.endpoint, {
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error(String(res.status));
const data = await res.json();
// `services` is the legacy alias some backends still emit.
this._neighbors = data.neighbors || data.services || [];
} catch {
// Silent: the menu is navigation, not a health indicator. Keep whatever
// we last had rather than blanking the list on one failed poll.
}
this._render();
}
_setOpen(open) {
this._open = open;
this._panel.hidden = !open;
this._btn.setAttribute("aria-expanded", String(open));
if (open) this.refresh();
}
_render() {
const panel = this._panel;
panel.textContent = "";
const seen = new Set();
const rows = [];
for (const n of this._neighbors) {
const name = n.name || "";
if (!name || seen.has(name)) continue;
// Beacon v2 neighbours include plugins; the nav lists services only.
// (A row without `caps` comes from a pre-v2 backend — keep it.)
const isService = (c) => String(c).startsWith("metamesh.service/");
if (Array.isArray(n.caps) && !n.caps.some(isService)) continue;
seen.add(name);
rows.push(n);
}
// Always show the host itself, even before its own echo comes back.
if (this.current && !seen.has(this.current)) {
rows.push({ name: this.current, baseUrl: "" });
}
rows.sort((a, b) => String(a.name).localeCompare(String(b.name)));
if (rows.length === 0) {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = "No services found";
panel.appendChild(empty);
return;
}
for (const n of rows) {
const isActive = n.name === this.current;
let url = n.baseUrl || "";
if (typeof this.rewriteUrl === "function") {
try {
url = this.rewriteUrl(n.name, url, n) || "";
} catch {
/* a bad hook must not blank the menu */
}
}
// A neighbour with no reachable URL is shown but not linked — better
// than a link that goes nowhere.
const el = document.createElement(isActive || !url ? "span" : "a");
el.className =
"item" + (isActive ? " active" : "") + (!isActive && !url ? " dead" : "");
if (el.tagName === "A") {
el.href = url;
el.setAttribute("role", "menuitem");
}
if (!isActive && !url) el.title = "No reachable URL advertised";
const icon = document.createElement("span");
icon.className = "icon";
icon.textContent = ICONS[n.name] || DEFAULT_ICON;
const label = document.createElement("span");
label.textContent = titleCase(n.name);
el.append(icon, label);
// Beacon v2 omits `status` when running.
if ((n.status || "running") === "running" || isActive) {
const dot = document.createElement("span");
dot.className = "dot";
dot.title = n.status || "running";
el.appendChild(dot);
}
panel.appendChild(el);
}
}
}
if (!customElements.get("meta-service-menu")) {
customElements.define("meta-service-menu", MetaServiceMenu);
}
export default MetaServiceMenu;
+171 -71
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getJSON } from '../lib/api';
interface HealthStatus { interface HealthStatus {
status: string; status: string;
@@ -6,6 +7,22 @@ interface HealthStatus {
timestamp: string; timestamp: string;
} }
interface FileStats {
count: number;
totalSize: number;
}
interface Stats {
records: number;
redisKeys: number;
redisMemory: string;
identities: number;
files: FileStats | null;
udlUsers: number | null;
sweptAt: number;
sweeping: boolean;
}
interface WatcherStatus { interface WatcherStatus {
id: string; id: string;
path: string; path: string;
@@ -22,13 +39,6 @@ interface WatchersResponse {
count: number; count: number;
} }
interface ServiceInfo {
name: string;
api: string;
capabilities: string[];
timestamp: number;
}
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
background: '#16213e', background: '#16213e',
borderRadius: '8px', borderRadius: '8px',
@@ -38,53 +48,110 @@ const cardStyle: React.CSSProperties = {
const gridStyle: React.CSSProperties = { const gridStyle: React.CSSProperties = {
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', // 200px, not 220: five tiles must fit one row inside the 1200px container
// (1136 content - 4x16 gap = 1072 / 5 = 214 each) before wrapping.
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '1rem', gap: '1rem',
marginBottom: '1rem',
}; };
const statStyle: React.CSSProperties = { const statStyle: React.CSSProperties = {
fontSize: '2rem', fontSize: '2rem',
fontWeight: 'bold', fontWeight: 'bold',
color: '#4ade80', color: '#4ade80',
lineHeight: 1.2,
}; };
const labelStyle: React.CSSProperties = {
marginBottom: '0.75rem',
color: '#888',
fontSize: '0.85rem',
textTransform: 'uppercase',
letterSpacing: '0.04em',
};
const noteStyle: React.CSSProperties = {
color: '#6b7a99',
fontSize: '0.8rem',
marginTop: '0.4rem',
};
const numberFmt = new Intl.NumberFormat();
function formatCount(n: number | null | undefined): string {
return n === null || n === undefined ? '—' : numberFmt.format(n);
}
// Binary units: this counts bytes on disk, and every other tool the operator
// compares against (du, docker, the mount page) reports GiB.
function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) return '—';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value.toFixed(value >= 100 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function Tile({
label,
value,
note,
pending,
}: {
label: string;
value: string;
note?: string;
pending?: boolean;
}) {
return (
<div style={cardStyle}>
<div style={labelStyle}>{label}</div>
<div style={{ ...statStyle, color: pending ? '#6b7a99' : statStyle.color }}>
{value}
</div>
{note && <div style={noteStyle}>{note}</div>}
</div>
);
}
export default function Dashboard() { export default function Dashboard() {
const [health, setHealth] = useState<HealthStatus | null>(null); const [health, setHealth] = useState<HealthStatus | null>(null);
const [stats, setStats] = useState<Stats | null>(null);
const [watchersData, setWatchersData] = useState<WatchersResponse | null>(null); const [watchersData, setWatchersData] = useState<WatchersResponse | null>(null);
const [services, setServices] = useState<ServiceInfo[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false;
const fetchData = async () => { const fetchData = async () => {
try { // Settled, not all: one dead endpoint must not blank every tile.
// Fetch health const [healthRes, statsRes, watchersRes] = await Promise.allSettled([
const healthRes = await fetch('/health'); getJSON<HealthStatus>('/health'),
if (healthRes.ok) { getJSON<Stats>('/api/stats'),
setHealth(await healthRes.json()); getJSON<WatchersResponse>('/api/watchers'),
} ]);
if (cancelled) return;
// Fetch watchers status if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
const watchersRes = await fetch('/api/watchers'); if (statsRes.status === 'fulfilled') setStats(statsRes.value);
if (watchersRes.ok) { if (watchersRes.status === 'fulfilled') setWatchersData(watchersRes.value);
setWatchersData(await watchersRes.json());
}
// Fetch services const failures = [healthRes, statsRes, watchersRes]
const servicesRes = await fetch('/services'); .filter((r): r is PromiseRejectedResult => r.status === 'rejected')
if (servicesRes.ok) { .map((r) => String(r.reason?.message ?? r.reason));
const data = await servicesRes.json(); setError(failures.length ? failures.join(' · ') : null);
setServices(data.services || []);
}
setError(null);
} catch (err) {
setError(String(err));
}
}; };
fetchData(); fetchData();
const interval = setInterval(fetchData, 5000); const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval); return () => {
cancelled = true;
clearInterval(interval);
};
}, []); }, []);
const handleTriggerScan = async () => { const handleTriggerScan = async () => {
@@ -95,49 +162,102 @@ export default function Dashboard() {
} }
}; };
// Aggregate watcher stats const isScanning = watchersData?.watchers.some((w) => w.isScanning) || false;
const totalFiles = watchersData?.watchers.reduce((sum, w) => sum + w.fileCount, 0) || 0; const activeWatchers = watchersData?.watchers.filter((w) => w.active).length || 0;
const isScanning = watchersData?.watchers.some(w => w.isScanning) || false; const lastScan =
const activeWatchers = watchersData?.watchers.filter(w => w.active).length || 0; watchersData?.watchers.reduce((max, w) => Math.max(max, w.lastScan || 0), 0) || 0;
const lastScan = watchersData?.watchers.reduce((max, w) => Math.max(max, w.lastScan || 0), 0) || 0;
// Records we hold metadata for but have no local file: gateway/cid-rooted
// entries, plus anything whose file went away. The interesting number on a
// client box, where it should be most of them.
const fileless =
stats && stats.files ? Math.max(0, stats.records - stats.files.count) : null;
const sweepPending = !!stats && !stats.files;
const sweptNote = (() => {
if (!stats) return undefined;
if (sweepPending) return stats.sweeping ? 'scanning…' : 'not scanned yet';
if (stats.sweeping) return 'refreshing…';
if (stats.sweptAt) return `as of ${new Date(stats.sweptAt).toLocaleTimeString()}`;
return undefined;
})();
return ( return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}> <div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto', overflowY: 'auto', height: '100%' }}>
<h1 style={{ marginBottom: '2rem' }}>meta-core Dashboard</h1> <h1 style={{ marginBottom: '2rem' }}>meta-core Dashboard</h1>
{error && ( {error && (
<div style={{ ...cardStyle, background: '#4a1a1a', color: '#f87171' }}> <div style={{ ...cardStyle, background: '#4a1a1a', color: '#f87171' }}>
Error: {error} {error}
</div> </div>
)} )}
<div style={gridStyle}> <div style={gridStyle}>
{/* Health Card */} <Tile
label="Files discovered"
value={formatCount(stats?.files?.count)}
note={sweptNote}
pending={sweepPending}
/>
<Tile
label="Library size"
value={formatBytes(stats?.files?.totalSize)}
note={sweptNote}
pending={sweepPending}
/>
<Tile
label="Metadata entries"
value={formatCount(stats?.records)}
note={
fileless === null
? 'records in the index'
: `${formatCount(fileless)} with no local file`
}
/>
<Tile
label="Store keys"
value={formatCount(stats?.redisKeys)}
note={stats?.redisMemory ? `${stats.redisMemory} on disk` : undefined}
/>
<Tile
label="Identities"
value={formatCount(stats?.identities)}
note={
stats?.udlUsers !== null && stats?.udlUsers !== undefined
? `${formatCount(stats.udlUsers)} with user data`
: 'signing accounts'
}
/>
</div>
<div style={{ ...gridStyle, gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))' }}>
<div style={cardStyle}> <div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Service Health</h3> <div style={labelStyle}>Service health</div>
{health ? ( {health ? (
<> <>
<div style={statStyle}> <div style={statStyle}>
{health.status === 'ok' ? 'Healthy' : health.status} {health.status === 'ok' ? 'Healthy' : health.status}
</div> </div>
<p>Redis: {health.redis ? 'Connected' : 'Disconnected'}</p> <p style={noteStyle}>
Metadata store: {health.redis ? 'Open' : 'Unavailable'}
</p>
</> </>
) : ( ) : (
<p>Loading...</p> <p style={noteStyle}>Loading…</p>
)} )}
</div> </div>
{/* Watchers Status Card */}
<div style={cardStyle}> <div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>File Watchers</h3> <div style={labelStyle}>File watchers</div>
{watchersData ? ( {watchersData ? (
<> <>
<div style={statStyle}>{totalFiles}</div> <div style={statStyle}>
<p>Files discovered</p> {activeWatchers} / {watchersData.count}
<p>Watchers: {activeWatchers} active / {watchersData.count} total</p> </div>
<p>Status: {isScanning ? 'Scanning...' : 'Idle'}</p> <p style={noteStyle}>active watchers</p>
<p style={noteStyle}>Status: {isScanning ? 'Scanning…' : 'Idle'}</p>
{lastScan > 0 && ( {lastScan > 0 && (
<p>Last scan: {new Date(lastScan).toLocaleString()}</p> <p style={noteStyle}>Last scan: {new Date(lastScan).toLocaleString()}</p>
)} )}
<button <button
onClick={handleTriggerScan} onClick={handleTriggerScan}
@@ -155,27 +275,7 @@ export default function Dashboard() {
</button> </button>
</> </>
) : ( ) : (
<p>Loading...</p> <p style={noteStyle}>Loading…</p>
)}
</div>
{/* Services Card */}
<div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Connected Services</h3>
<div style={statStyle}>{services.length}</div>
<p>Services registered</p>
{services.length > 0 && (
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
{services.map((svc) => (
<li key={svc.name} style={{ marginBottom: '0.5rem' }}>
<strong>{svc.name}</strong>
<br />
<span style={{ color: '#888', fontSize: '0.9rem' }}>
{svc.capabilities?.join(', ') || 'No capabilities'}
</span>
</li>
))}
</ul>
)} )}
</div> </div>
</div> </div>
+604 -120
View File
@@ -1,21 +1,74 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
// Status returned by GET /api/identity. Private key is never present here — import {
// it is shown exactly once at generation time (see GeneratedKeyPanel). INSECURE_CONTEXT_MESSAGE,
interface IdentityStatus { canSign,
hasIdentity: boolean; proveOwnership,
uid?: string; uidFromSecretKey,
curve?: string; type IdentityAction,
createdAt?: number; } from '../lib/sign';
}
interface GeneratedKey { // Admin console for meta-core's account keystore.
//
// meta-core stores one secp256k1 keypair per file under
// {META_CORE_PATH}/identity/accounts/<uid>.json and has NO notion of a current
// user — which account is "you" is decided by whichever client signs in
// (meta-watch). So this page lists accounts; it never speaks of an active one.
//
// The private key is returned by exactly two calls: POST /api/identity/generate
// (once, at creation) and POST /api/identity/reveal (on demand, for an account
// that already exists). Both land in the same exclusive SecretKeyPanel.
//
// Reveal and remove both require the account's own secret key, entered here and
// used to sign a one-shot challenge locally — the key itself is never sent. So
// this page can enumerate and create, but it holds no authority over an
// existing account that its operator does not already have the key for. There
// is deliberately no admin override: on this node the private key *is* the
// account.
interface Account {
uid: string; uid: string;
curve: string; curve: string;
createdAt: number; createdAt: number;
privateKeyHex: string;
} }
// What one account holds in the User Data Layer. Absent from
// /api/udl/users/stats means zero, not unknown.
interface Stats {
records: number;
cids: number;
keys: number;
}
// A secret on screen. Carries no createdAt: /api/identity/reveal answers with
// {uid, curve, privateKeyHex} only.
interface SecretKeyView {
uid: string;
privateKeyHex: string;
kind: 'generated' | 'revealed';
}
// Which row has an open confirmation, and for what. One discriminated value
// rather than a pair of ids, so at most one confirm panel can ever be open.
interface Pending {
uid: string;
kind: 'remove' | 'reveal';
}
// Both confirmable operations map to a server-side action name, and the
// challenge is bound to it: a challenge minted to reveal a key cannot delete
// the account. Keeping the mapping in one place stops the two drifting.
const ACTION_FOR: Record<Pending['kind'], IdentityAction> = {
reveal: 'reveal',
remove: 'delete',
};
// meta-watch's sentinel cell for profile records: display name and avatar are
// about the *user*, not a content file, so they live at (uid, "self", ...).
// meta-core stores them opaquely and knows nothing of the convention.
const PROFILE_CID = 'self';
const PROFILE_NAME_KEY = 'profile:name';
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
background: '#16213e', background: '#16213e',
borderRadius: '8px', borderRadius: '8px',
@@ -59,6 +112,21 @@ const monoStyle: React.CSSProperties = {
borderRadius: '4px', borderRadius: '4px',
}; };
const dangerPanelStyle: React.CSSProperties = {
background: '#1f0a0a',
border: '1px solid #7f1d1d',
padding: '0.75rem 1rem',
borderRadius: '4px',
marginTop: '0.75rem',
};
const errorTextStyle: React.CSSProperties = {
color: '#f87171',
background: '#1f0a0a',
padding: '0.5rem 0.75rem',
borderRadius: '4px',
};
function formatCreatedAt(unixSec?: number): string { function formatCreatedAt(unixSec?: number): string {
if (!unixSec) return ''; if (!unixSec) return '';
try { try {
@@ -79,25 +147,165 @@ async function errorMessage(res: Response, fallback: string): Promise<string> {
return `${fallback} (HTTP ${res.status})`; return `${fallback} (HTTP ${res.status})`;
} }
function GeneratedKeyPanel({ data, onDismiss }: { data: GeneratedKey; onDismiss: () => void }) { // Copy text, falling back to selecting the element that displays it.
const [copied, setCopied] = useState(false); //
const copy = async () => { // The fallback is load-bearing, not politeness: the dashboard is served over
// plain HTTP, where navigator.clipboard is undefined — without it the copy
// buttons do nothing at all on a real deployment.
async function copyToClipboard(text: string, fallbackElementId: string): Promise<void> {
try { try {
await navigator.clipboard.writeText(data.privateKeyHex); await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch { } catch {
// Clipboard API blocked (non-HTTPS context, etc.) — fall back to selection.
const sel = window.getSelection(); const sel = window.getSelection();
const range = document.createRange(); const el = document.getElementById(fallbackElementId);
const el = document.getElementById('mm-privkey-box');
if (sel && el) { if (sel && el) {
const range = document.createRange();
range.selectNodeContents(el); range.selectNodeContents(el);
sel.removeAllRanges(); sel.removeAllRanges();
sel.addRange(range); sel.addRange(range);
} }
} }
}
async function fetchAccounts(): Promise<Account[]> {
const res = await fetch('/api/identity/accounts');
if (!res.ok) throw new Error(await errorMessage(res, 'Failed to load accounts'));
const data = (await res.json()) as { accounts?: Account[] };
// Backend order is CreatedAt ascending then uid (identity.List) — stable, so
// render it as given rather than re-sorting here.
return data.accounts ?? [];
}
// 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
// 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*
// — meta-core stores the plaintext beside the signed record and verifies
// nothing — so the uid stays on screen as the real identity, and the length cap
// bounds what a hostile writer can do to the layout.
async function fetchDisplayName(uid: string): Promise<string | null> {
const url =
`/api/udl/record?uid=${encodeURIComponent(uid)}` +
`&cid=${encodeURIComponent(PROFILE_CID)}` +
`&key=${encodeURIComponent(PROFILE_NAME_KEY)}`;
try {
const res = await fetch(url);
if (!res.ok) return null; // 503 when storage is down, 500, …
const data = (await res.json()) as { exists?: boolean; value?: unknown };
// Key off `value`, not `exists`: clearing a name writes a tombstone, which
// leaves the cell existing with no plaintext. Private-tier cells look the
// same, which is exactly right — there is nothing to show for either.
if (typeof data.value !== 'string') return null;
const name = data.value.trim();
return name ? name.slice(0, 64) : null;
} catch {
return null;
}
}
// How much each account holds. One call for the whole page, not one per row:
// meta-core answers it from a keyspace SCAN (there is no per-user cid index),
// so per-row calls would walk the keyspace once per account.
//
// 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 the store is
// down — accounts are file-backed, their data is not.
//
// Returns null on failure rather than an empty map. The difference is
// load-bearing: an empty map renders as "no stored data" against every account,
// which inside a delete confirmation is a false statement about what is at
// stake. Unknown has to look like unknown.
async function fetchStats(): Promise<Record<string, Stats> | null> {
try {
const res = await fetch('/api/udl/users/stats');
if (!res.ok) return null;
const data = (await res.json()) as { users?: Record<string, Stats> };
return data.users ?? {};
} catch {
return null;
}
}
// The secret-key box shared by the reveal and remove confirmations.
//
// Separate from the surrounding panel so both operations ask the same way, and
// so the key lives in this component's state rather than the page's — it is
// discarded with the panel instead of lingering in a parent that outlives it.
function SecretKeyConfirm({
label,
actionLabel,
busy,
onConfirm,
onCancel,
}: {
label: string;
actionLabel: string;
busy: boolean;
onConfirm: (secretKeyHex: string) => void;
onCancel: () => void;
}) {
const [secret, setSecret] = useState('');
const signable = canSign();
// Derived locally, purely to tell the operator they have the wrong key before
// they hit the button. The server would refuse either way.
const derived = secret.trim() ? uidFromSecretKey(secret) : null;
const mismatch = Boolean(secret.trim() && derived && derived !== label);
return (
<form
onSubmit={(e) => {
e.preventDefault();
onConfirm(secret.trim());
}}
>
<p style={{ color: '#fecaca', margin: '0 0 0.5rem' }}>
Paste this account's secret key to prove it is yours. It is used to sign a one-time
challenge <em>in this browser</em> and is never sent to the server.
</p>
{!signable && <p style={errorTextStyle}>{INSECURE_CONTEXT_MESSAGE}</p>}
<input
style={inputStyle}
type="password"
autoComplete="off"
placeholder="64 hex characters"
value={secret}
onChange={(e) => setSecret(e.target.value)}
disabled={busy || !signable}
autoFocus
/>
{mismatch && (
<p style={{ ...errorTextStyle, marginTop: 0 }}>
That key belongs to a different account ({derived}).
</p>
)}
<button
type="submit"
style={dangerButtonStyle}
disabled={busy || !signable || !secret.trim() || mismatch}
>
{busy ? 'Verifying…' : actionLabel}
</button>
<button type="button" style={buttonStyle} onClick={onCancel} disabled={busy}>
Cancel
</button>
</form>
);
}
// The one place a private key is ever displayed. Rendered exclusively (the
// account list is hidden while it is open) so the "save it now" contract of a
// freshly generated key is not competing with anything, and so the hardcoded
// element id below stays unique.
function SecretKeyPanel({ data, onDismiss }: { data: SecretKeyView; onDismiss: () => void }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
await copyToClipboard(data.privateKeyHex, 'mm-privkey-box');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}; };
return ( return (
<div style={{ ...cardStyle, border: '2px solid #dc2626' }}> <div style={{ ...cardStyle, border: '2px solid #dc2626' }}>
<div <div
@@ -109,9 +317,19 @@ function GeneratedKeyPanel({ data, onDismiss }: { data: GeneratedKey; onDismiss:
marginBottom: '1rem', marginBottom: '1rem',
}} }}
> >
{data.kind === 'generated' ? (
<>
<strong>This private key is shown ONCE.</strong> Save it now in a password manager. <strong>This private key is shown ONCE.</strong> Save it now in a password manager.
It will never be displayed again — there is no recovery path. Losing it means losing access You can ask for it again later with <em>Reveal private key</em>, but only for as long
to every record signed under this identity. as this account exists on this node — delete it and the key is gone for good.
</>
) : (
<>
<strong>This is the live private key for an existing account.</strong> Anyone holding
it can sign as this uid forever, on any machine. There is no revocation and no
rotation. Do not paste it into chat, tickets or screenshots.
</>
)}
</div> </div>
<p style={{ color: '#9ca3af', margin: '0 0 0.5rem' }}>uid (public):</p> <p style={{ color: '#9ca3af', margin: '0 0 0.5rem' }}>uid (public):</p>
<div style={monoStyle}>{data.uid}</div> <div style={monoStyle}>{data.uid}</div>
@@ -124,36 +342,40 @@ function GeneratedKeyPanel({ data, onDismiss }: { data: GeneratedKey; onDismiss:
{copied ? 'Copied' : 'Copy private key'} {copied ? 'Copied' : 'Copy private key'}
</button> </button>
<button style={dangerButtonStyle} onClick={onDismiss}> <button style={dangerButtonStyle} onClick={onDismiss}>
I have saved it {data.kind === 'generated' ? 'I have saved it' : 'Done — hide it'}
</button> </button>
</div> </div>
</div> </div>
); );
} }
function NoIdentityPanel({ // Create / import. Always available, not just on an empty keystore: this is an
// admin console, so adding an account to a populated node has to be possible.
function NewAccountPanel({
onGenerated, onGenerated,
onImported, onImported,
}: { }: {
onGenerated: (g: GeneratedKey) => void; onGenerated: (s: SecretKeyView) => void;
onImported: () => void; onImported: () => void;
}) { }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [importHex, setImportHex] = useState(''); const [importHex, setImportHex] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const generate = async () => { const generate = async () => {
setBusy(true); setBusy(true);
setError(null); setError(null);
setNotice(null);
try { try {
const res = await fetch('/api/identity/generate', { method: 'POST' }); const res = await fetch('/api/identity/generate', { method: 'POST' });
if (!res.ok) { if (!res.ok) {
setError(await errorMessage(res, 'Generate failed')); setError(await errorMessage(res, 'Generate failed'));
return; return;
} }
const data = (await res.json()) as GeneratedKey; const data = (await res.json()) as { uid: string; privateKeyHex: string };
onGenerated(data); onGenerated({ uid: data.uid, privateKeyHex: data.privateKeyHex, kind: 'generated' });
} catch (e) { } catch (e) {
setError(`Generate failed: ${e instanceof Error ? e.message : String(e)}`); setError(`Generate failed: ${e instanceof Error ? e.message : String(e)}`);
} finally { } finally {
@@ -165,6 +387,7 @@ function NoIdentityPanel({
e.preventDefault(); e.preventDefault();
setBusy(true); setBusy(true);
setError(null); setError(null);
setNotice(null);
try { try {
const res = await fetch('/api/identity/import', { const res = await fetch('/api/identity/import', {
method: 'POST', method: 'POST',
@@ -175,6 +398,15 @@ function NoIdentityPanel({
setError(await errorMessage(res, 'Import failed')); setError(await errorMessage(res, 'Import failed'));
return; return;
} }
// Import is idempotent — presenting the key IS the proof of ownership, so
// a key this node already holds is a success, not a conflict. `created`
// only decides the wording.
const data = (await res.json()) as { created?: boolean };
setNotice(
data.created
? 'Imported — a new account was added to this keystore.'
: 'That key was already in this keystore; nothing changed.'
);
setImportHex(''); setImportHex('');
setImporting(false); setImporting(false);
onImported(); onImported();
@@ -187,15 +419,16 @@ function NoIdentityPanel({
return ( return (
<div style={cardStyle}> <div style={cardStyle}>
<h3 style={{ marginTop: 0 }}>No identity yet</h3> <h3 style={{ marginTop: 0 }}>Add an account</h3>
<p style={{ color: '#9ca3af' }}> <p style={{ color: '#9ca3af', marginTop: 0 }}>
The User Data Layer needs a signing key for your ratings, watch progress, and "liked" flags Generating mints a fresh secp256k1 keypair here. Importing adds a key created elsewhere —
to be attributed to you across devices. Nothing happens automatically — generate one below, it is also how an existing account is moved onto this node, so importing a key the
or import a key you already have. keystore already holds succeeds and changes nothing.
</p> </p>
{error && ( {error && <p style={errorTextStyle}>{error}</p>}
<p style={{ color: '#f87171', background: '#1f0a0a', padding: '0.5rem 0.75rem', borderRadius: '4px' }}> {notice && (
{error} <p style={{ color: '#9ca3af', background: '#0a0a18', padding: '0.5rem 0.75rem', borderRadius: '4px' }}>
{notice}
</p> </p>
)} )}
<div> <div>
@@ -235,73 +468,156 @@ function NoIdentityPanel({
); );
} }
function HasIdentityPanel({ status, onReplace }: { status: IdentityStatus; onReplace: () => void }) { function AccountCard({
const [confirming, setConfirming] = useState(false); account,
const [busy, setBusy] = useState(false); name,
const [error, setError] = useState<string | null>(null); stats,
busy,
disabled,
pending,
error,
onAsk,
onCancel,
onReveal,
onRemove,
}: {
account: Account;
name?: string;
// undefined means "counted, and it holds nothing"; null means "could not be
// counted". The two must not read the same on a confirmation screen.
stats?: Stats | null;
busy: boolean;
disabled: boolean;
pending: Pending | null;
error: string | null;
onAsk: (p: Pending) => void;
onCancel: () => void;
onReveal: (uid: string, secretKeyHex: string) => void;
onRemove: (uid: string, secretKeyHex: string) => void;
}) {
const [copied, setCopied] = useState(false);
const uidBoxId = `mm-uid-${account.uid}`;
const label = name ?? account.uid;
const counted = stats !== null;
const records = stats?.records ?? 0;
const titles = stats?.cids ?? 0;
const remove = async () => { const copyUid = async () => {
setBusy(true); await copyToClipboard(account.uid, uidBoxId);
setError(null); setCopied(true);
try { setTimeout(() => setCopied(false), 2000);
const res = await fetch('/api/identity?confirm=true', { method: 'DELETE' });
if (!res.ok && res.status !== 204) {
setError(await errorMessage(res, 'Delete failed'));
return;
}
onReplace();
} catch (e) {
setError(`Delete failed: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setBusy(false);
setConfirming(false);
}
}; };
return ( return (
<div style={cardStyle}> <div style={cardStyle}>
<h3 style={{ marginTop: 0 }}>Active identity</h3> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem' }}>
<p style={{ color: '#9ca3af', margin: '0 0 0.5rem' }}>uid:</p> <div style={{ minWidth: 0, flex: 1 }}>
<div style={monoStyle}>{status.uid}</div> <h3 style={{ margin: '0 0 0.5rem', color: name ? '#fff' : '#888' }}>
<p style={{ color: '#888', marginTop: '1rem' }}> {name ?? 'Unnamed account'}
Curve: {status.curve} · Created: {formatCreatedAt(status.createdAt)} </h3>
<div id={uidBoxId} style={monoStyle}>
{account.uid}
</div>
<p style={{ color: '#888', margin: '0.75rem 0 0', fontSize: '0.85rem' }}>
Curve: {account.curve} · Created: {formatCreatedAt(account.createdAt)}
</p> </p>
<p style={{ color: '#9ca3af', fontSize: '0.85rem' }}> <p style={{ color: '#888', margin: '0.25rem 0 0', fontSize: '0.85rem' }}>
The private key lives at <code>/meta-core/identity/identity.json</code> on this volume and {/* An account that has never been used legitimately holds nothing, so
is never returned through the API after generation. Other services on this stack (e.g. zero is a real answer — but only once the count actually ran. */}
meta-watch) sign records by calling <code>POST /api/identity/sign</code>. {!counted
? 'Stored data: could not be counted (storage unavailable)'
: records === 0
? 'No stored data'
: `${records.toLocaleString()} stored ${records === 1 ? 'record' : 'records'}` +
` across ${titles.toLocaleString()} ${titles === 1 ? 'title' : 'titles'}`}
</p> </p>
{error && ( </div>
<p style={{ color: '#f87171', background: '#1f0a0a', padding: '0.5rem 0.75rem', borderRadius: '4px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', flexShrink: 0 }}>
{error} <button style={{ ...buttonStyle, marginRight: 0 }} onClick={copyUid} disabled={disabled}>
</p> {copied ? 'Copied' : 'Copy uid'}
)}
{!confirming ? (
<button style={dangerButtonStyle} onClick={() => setConfirming(true)} disabled={busy}>
Replace identity
</button> </button>
) : ( <button
<div style={{ ...dangerButtonStyle, marginRight: 0 }}
style={{ onClick={() => onAsk({ uid: account.uid, kind: 'reveal' })}
background: '#1f0a0a', disabled={disabled}
border: '1px solid #7f1d1d',
padding: '0.75rem 1rem',
borderRadius: '4px',
marginTop: '0.5rem',
}}
> >
Reveal private key
</button>
<button
style={{ ...dangerButtonStyle, marginRight: 0 }}
onClick={() => onAsk({ uid: account.uid, kind: 'remove' })}
disabled={disabled}
>
Remove
</button>
</div>
</div>
{error && <p style={{ ...errorTextStyle, marginBottom: 0 }}>{error}</p>}
{pending?.uid === account.uid && pending.kind === 'reveal' && (
<div style={dangerPanelStyle}>
<p style={{ color: '#fecaca', marginTop: 0 }}> <p style={{ color: '#fecaca', marginTop: 0 }}>
Replacing the identity deletes the current keypair from disk. Records signed by the Show the private key for <strong>{label}</strong>?
current uid will keep verifying for anyone who has them cached, but this node will no
longer be able to write under that uid. <strong>Make sure you have backed up the key
first.</strong>
</p> </p>
<button style={dangerButtonStyle} onClick={remove} disabled={busy}> <p style={{ color: '#fecaca', marginTop: 0 }}>
{busy ? 'Deleting…' : 'Yes, delete identity'} The key <em>is</em> the account. Anyone who copies it can sign records as this uid on
</button> any machine, forever — there is no way to revoke or rotate it afterwards. Because
<button style={buttonStyle} onClick={() => setConfirming(false)} disabled={busy}> proving ownership already requires the key, this only confirms that the copy this node
Cancel holds matches yours.
</button> </p>
<SecretKeyConfirm
label={account.uid}
actionLabel="Show the private key"
busy={busy}
onConfirm={(secret) => onReveal(account.uid, secret)}
onCancel={onCancel}
/>
</div>
)}
{pending?.uid === account.uid && pending.kind === 'remove' && (
<div style={dangerPanelStyle}>
<p style={{ color: '#fecaca', marginTop: 0 }}>
Remove <strong>{label}</strong> from this node?
</p>
<ul style={{ color: '#fecaca', paddingLeft: '1.2rem', margin: '0 0 0.75rem' }}>
<li>
<strong>The key is gone for good.</strong> The uid <em>is</em> the public key, and
the private half exists only in this node's keystore. If nobody saved it, nothing —
not you, not meta-core — can ever sign as this uid again. Reveal and copy it first
if there is any doubt.
</li>
<li>
<strong>Nothing is retracted.</strong> Records already signed under this uid keep
verifying on every peer that replicated them. Deleting a key is not a recall.
</li>
<li>
<strong>Its data is destroyed, not orphaned.</strong>{' '}
{!counted
? 'This account\u2019s records could not be counted because storage is unavailable — ' +
'so meta-core will refuse this delete rather than remove the key and strand them.'
: records === 0
? 'This account holds no User Data Layer records, so there is nothing to purge.'
: `All ${records.toLocaleString()} User Data Layer ${
records === 1 ? 'record' : 'records'
} — likes, ratings, watch progress across ${titles.toLocaleString()} ${
titles === 1 ? 'title' : 'titles'
} — are permanently deleted from the metadata store, along with every index that names this uid.`}{' '}
This cannot be undone and there is no backup.
</li>
<li>
<strong>This signs nobody out elsewhere.</strong> Any device that holds this key
still holds it, and can import it back here at any time.
</li>
</ul>
<SecretKeyConfirm
label={account.uid}
actionLabel="Remove this account and all its data"
busy={busy}
onConfirm={(secret) => onRemove(account.uid, secret)}
onCancel={onCancel}
/>
</div> </div>
)} )}
</div> </div>
@@ -309,67 +625,235 @@ function HasIdentityPanel({ status, onReplace }: { status: IdentityStatus; onRep
} }
export default function Identity() { export default function Identity() {
const [status, setStatus] = useState<IdentityStatus | null>(null); const [accounts, setAccounts] = useState<Account[] | null>(null);
const [generated, setGenerated] = useState<GeneratedKey | null>(null); const [names, setNames] = useState<Record<string, string>>({});
const [stats, setStats] = useState<Record<string, Stats> | null>({});
const [notice, setNotice] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null); const [fetchError, setFetchError] = useState<string | null>(null);
const [secret, setSecret] = useState<SecretKeyView | null>(null);
const [pending, setPending] = useState<Pending | null>(null);
const [busyUid, setBusyUid] = useState<string | null>(null);
const [rowError, setRowError] = useState<{ uid: string; message: string } | null>(null);
// Bumped per refresh so a slow response from a superseded load cannot
// overwrite a newer one. The page does not poll (unlike the rest of the
// dashboard) — a key list has no business changing under the cursor.
const reqGen = useRef(0);
const refresh = async () => { const refresh = async () => {
const gen = ++reqGen.current;
setLoading(true); setLoading(true);
setFetchError(null); setFetchError(null);
let list: Account[];
try { try {
const res = await fetch('/api/identity'); list = await fetchAccounts();
if (!res.ok) { } catch (e) {
setFetchError(await errorMessage(res, 'Failed to load identity status')); if (gen !== reqGen.current) return;
setFetchError(e instanceof Error ? e.message : String(e));
setLoading(false);
return; return;
} }
setStatus((await res.json()) as IdentityStatus); if (gen !== reqGen.current) return;
} catch (e) { // Render the list before resolving names, so a down store delays nothing.
setFetchError(`Failed to load identity status: ${e instanceof Error ? e.message : String(e)}`); setAccounts(list);
} finally {
setLoading(false); setLoading(false);
}
// Names and counts both come after the list is on screen, and neither can
// fail the page: a down store costs decoration, not the account list.
const [settled, counts] = await Promise.all([
Promise.allSettled(list.map((a) => fetchDisplayName(a.uid))),
fetchStats(),
]);
if (gen !== reqGen.current) return;
const next: Record<string, string> = {};
settled.forEach((r, i) => {
if (r.status === 'fulfilled' && r.value) next[list[i].uid] = r.value;
});
// Replace rather than merge, so a removed account's name and counts go with
// it instead of lingering against a uid that no longer exists.
setNames(next);
setStats(counts);
}; };
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, []); }, []);
const reveal = async (uid: string, secretKeyHex: string) => {
setBusyUid(uid);
setRowError(null);
try {
const proof = await proveOwnership(uid, ACTION_FOR.reveal, secretKeyHex);
// POST with a body rather than ?uid=, so neither the target nor the
// signature reaches the proxy's access log. The handler accepts the uid
// either way; the proof must be in the body.
const res = await fetch('/api/identity/reveal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: true, uid, ...proof }),
});
if (!res.ok) {
setRowError({ uid, message: await errorMessage(res, 'Reveal failed') });
return;
}
const data = (await res.json()) as { uid: string; privateKeyHex: string };
setPending(null);
setSecret({ uid: data.uid, privateKeyHex: data.privateKeyHex, kind: 'revealed' });
} catch (e) {
setRowError({ uid, message: `Reveal failed: ${e instanceof Error ? e.message : String(e)}` });
} finally {
setBusyUid(null);
}
};
const remove = async (uid: string, secretKeyHex: string) => {
setBusyUid(uid);
setRowError(null);
setNotice(null);
try {
const proof = await proveOwnership(uid, ACTION_FOR.remove, secretKeyHex);
// The uid is mandatory: the signature authorises one specific account, so
// the backend refuses an unqualified delete rather than guess a target.
const res = await fetch('/api/identity', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: true, uid, ...proof }),
});
if (!res.ok) {
setRowError({ uid, message: await errorMessage(res, 'Remove failed') });
return;
}
// Report what the purge actually removed. "Account removed" on its own
// would not distinguish a clean delete from one that stranded the data.
const data = (await res.json().catch(() => ({}))) as { purged?: Stats };
const purged = data.purged;
setNotice(
purged && purged.records > 0
? `Account removed. Purged ${purged.records.toLocaleString()} User Data Layer ` +
`${purged.records === 1 ? 'record' : 'records'} across ` +
`${purged.cids.toLocaleString()} ${purged.cids === 1 ? 'title' : 'titles'}.`
: 'Account removed. It held no User Data Layer records.'
);
setPending(null);
await refresh();
} catch (e) {
setRowError({ uid, message: `Remove failed: ${e instanceof Error ? e.message : String(e)}` });
} finally {
setBusyUid(null);
}
};
const dismissSecret = () => {
setSecret(null);
refresh();
};
const ask = (p: Pending) => {
setRowError(null);
setNotice(null);
setPending(p);
};
const showList = !loading && !fetchError && !secret;
return ( return (
// App.tsx's content area is `overflow: hidden`, so every page owns its own
// scrolling (Files.tsx does the same). Without this the account list is
// simply clipped at the viewport with no scrollbar — invisible on a node
// with two accounts, and a dead end on one with fifteen.
//
// The scroller is the outer element and the padding lives inside it, so the
// scrollbar sits at the edge of the content area rather than floating in
// 2rem of whitespace, and `margin: 0 auto` still centres the column.
<div style={{ height: '100%', overflowY: 'auto', minHeight: 0 }}>
<div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}> <div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}>
<h1>Identity</h1> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 style={{ marginBottom: 0 }}>Identity</h1>
<button style={{ ...buttonStyle, marginRight: 0 }} onClick={refresh} disabled={loading}>
{loading ? 'Loading…' : 'Refresh'}
</button>
</div>
<p style={{ color: '#9ca3af' }}> <p style={{ color: '#9ca3af' }}>
Signing key for the User Data Layer (per-user ratings, progress, "liked" flags). One Every signing key in this node's keystore. meta-core has no "current user" — it stores
identity per node. The same key on another device makes that device count as "you". accounts, and which one is <em>you</em> is decided by whichever client signs in (e.g.
meta-watch). Keys live one JSON file per account under{' '}
<code>{'{META_CORE_PATH}'}/identity/accounts/&lt;uid&gt;.json</code>, and are used to sign
User Data Layer records (ratings, watch progress, "liked" flags).
</p> </p>
<div style={{ ...dangerPanelStyle, marginTop: 0, marginBottom: '1rem' }}>
<p style={{ color: '#fecaca', margin: 0 }}>
<strong>The key is the only authority.</strong> Revealing or removing an account requires
that account's secret key, entered here and used to sign a one-time challenge in your
browser — there is no admin override, and holding this page is not one. Listing and
creating accounts are still open to anything that can reach this API, so the perimeter in
front of this port is what keeps the roster private.
</p>
<p style={{ color: '#fecaca', margin: '0.5rem 0 0' }}>
What this does <em>not</em> defend against: anyone with filesystem access to{' '}
<code>{'{META_CORE_PATH}'}/identity/accounts/</code> reads every private key directly.
The keys are stored in plaintext, matching the trust model of the rest of{' '}
<code>/meta-core</code>.
</p>
</div>
{notice && (
<div style={{ ...cardStyle, border: '1px solid #0f3460' }}>
<p style={{ color: '#9ca3af', margin: 0 }}>{notice}</p>
</div>
)}
{loading && <div style={cardStyle}>Loading…</div>} {loading && <div style={cardStyle}>Loading…</div>}
{fetchError && ( {fetchError && (
<div style={{ ...cardStyle, border: '1px solid #7f1d1d' }}> <div style={{ ...cardStyle, border: '1px solid #7f1d1d' }}>
<p style={{ color: '#f87171', margin: 0 }}>{fetchError}</p> <p style={{ color: '#f87171', margin: '0 0 1rem' }}>{fetchError}</p>
<button style={buttonStyle} onClick={refresh}> <button style={buttonStyle} onClick={refresh}>
Retry Retry
</button> </button>
</div> </div>
)} )}
{!loading && !fetchError && generated && ( {!loading && !fetchError && secret && <SecretKeyPanel data={secret} onDismiss={dismissSecret} />}
<GeneratedKeyPanel
data={generated} {showList && (
onDismiss={() => { <>
setGenerated(null); <NewAccountPanel onGenerated={(s) => setSecret(s)} onImported={refresh} />
refresh();
}} <h2 style={{ fontSize: '1.1rem', color: '#9ca3af', margin: '1.5rem 0 1rem' }}>
Accounts ({accounts?.length ?? 0})
</h2>
{accounts && accounts.length === 0 && (
<div style={cardStyle}>
<p style={{ color: '#9ca3af', margin: 0 }}>
No accounts in this keystore. Accounts are normally created by a client when
someone signs up; use <em>Add an account</em> above if you are bootstrapping this
node.
</p>
</div>
)}
{accounts?.map((a) => (
<AccountCard
key={a.uid}
account={a}
name={names[a.uid]}
stats={stats === null ? null : stats[a.uid]}
busy={busyUid === a.uid}
disabled={busyUid !== null}
pending={pending}
error={rowError?.uid === a.uid ? rowError.message : null}
onAsk={ask}
onCancel={() => setPending(null)}
onReveal={reveal}
onRemove={remove}
/> />
))}
</>
)} )}
</div>
{!loading && !fetchError && !generated && status && !status.hasIdentity && (
<NoIdentityPanel onGenerated={(g) => setGenerated(g)} onImported={refresh} />
)}
{!loading && !fetchError && !generated && status && status.hasIdentity && (
<HasIdentityPanel status={status} onReplace={refresh} />
)}
</div> </div>
); );
} }
+1 -2
View File
@@ -9,8 +9,7 @@ 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/services
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
+8 -29
View File
@@ -2,9 +2,14 @@
# ============================================================================= # =============================================================================
# Leader Election Script for meta-core # Leader Election Script for meta-core
# #
# Uses flock for leader election. Only the leader runs the full application # The flock here is a MUTEX, not service discovery. It is the only thing
# stack (supervisord with Redis, meta-core, nginx, rclone, mount-watcher). # stopping two containers that mount the same /meta-core volume from putting
# Followers remain dormant, periodically retrying to acquire the lock. # two writers on one database file (/meta-core/db/meta-core.db). Sibling
# services no longer read anything from this directory — they locate meta-core
# 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
# corruption it prevents is unrecoverable.
# #
# Environment variables: # Environment variables:
# META_CORE_PATH - Path to meta-core data directory (default: /meta-core) # META_CORE_PATH - Path to meta-core data directory (default: /meta-core)
@@ -20,7 +25,6 @@ META_CORE_HTTP_PORT="${META_CORE_HTTP_PORT:-9000}"
ELECTION_RETRY_SECS="${ELECTION_RETRY_SECS:-5}" ELECTION_RETRY_SECS="${ELECTION_RETRY_SECS:-5}"
LOCK_FILE="${META_CORE_PATH}/locks/kv-leader.lock" LOCK_FILE="${META_CORE_PATH}/locks/kv-leader.lock"
INFO_FILE="${META_CORE_PATH}/locks/kv-leader.info"
SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord.conf" SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord.conf"
# Get the container's IP address # Get the container's IP address
@@ -40,35 +44,13 @@ get_local_ip() {
echo "$ip" echo "$ip"
} }
# Write leader info file (plain text API URL)
write_leader_info() {
local ip=$(get_local_ip)
local api_url="http://${ip}:${META_CORE_HTTP_PORT}"
# Write atomically using temp file + rename
local temp_file="${INFO_FILE}.tmp"
echo "$api_url" > "$temp_file"
mv "$temp_file" "$INFO_FILE"
echo "[election] Wrote leader info: $api_url"
}
# Clean up leader info file on shutdown
cleanup() {
echo "[election] Cleaning up..."
rm -f "$INFO_FILE"
exit 0
}
# Set up signal handlers for graceful shutdown # Set up signal handlers for graceful shutdown
trap cleanup SIGTERM SIGINT
# Ensure lock directory exists # Ensure lock directory exists
mkdir -p "$(dirname "$LOCK_FILE")" mkdir -p "$(dirname "$LOCK_FILE")"
echo "[election] Starting leader election..." echo "[election] Starting leader election..."
echo "[election] Lock file: $LOCK_FILE" echo "[election] Lock file: $LOCK_FILE"
echo "[election] Info file: $INFO_FILE"
# Open lock file for flock (file descriptor 200) # Open lock file for flock (file descriptor 200)
exec 200>"$LOCK_FILE" exec 200>"$LOCK_FILE"
@@ -79,9 +61,6 @@ while true; do
if flock -n 200; then if flock -n 200; then
echo "[election] Acquired flock - transitioning to LEADER" echo "[election] Acquired flock - transitioning to LEADER"
# Write leader info before starting services
write_leader_info
# exec supervisord - replaces this process # exec supervisord - replaces this process
# When supervisord exits, the flock is released automatically # When supervisord exits, the flock is released automatically
echo "[election] Starting supervisord..." echo "[election] Starting supervisord..."
+4 -8
View File
@@ -123,14 +123,10 @@ http {
# ========================================================================= # =========================================================================
# Service discovery # Service discovery
# ========================================================================= # =========================================================================
location /services { # meta-discovery v1 neighbours (supersedes /services, which stays
proxy_pass http://meta-core-api/services; # while consumers migrate).
proxy_http_version 1.1; location /neighbors {
proxy_set_header Host $host; proxy_pass http://meta-core-api/neighbors;
}
location /services/ {
proxy_pass http://meta-core-api/services/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
} }
+3 -17
View File
@@ -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"
+52 -12
View File
@@ -9,6 +9,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
@@ -85,7 +95,7 @@ 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 the `file:*`, `cid:*`, `file:__index__` (now `idx:*`), and `meta-core:*` keyspaces. No
other service can write — not because of an honoured convention, but other service can write — not because of an honoured convention, but
because they can't reach Redis at all. because they can't reach Redis at all.
@@ -243,6 +253,29 @@ 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}`) are appended by the
storage engine **in the same transaction** as the `file:` key change —
`del` only when the key existed, `set` only when the value actually
changed (since v1.2.0; an unchanged write is a no-op). The Redis
keyspace-notification hop (PSUBSCRIBE in `MetaPublisher`), which could
drop events when it lagged, is gone.
- **`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 +287,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 +307,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 +344,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
@@ -562,11 +600,11 @@ 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?** | **`/meta/{hash}` family** | Has GET/PUT/**PATCH**/DELETE plus `/_add/{key}` and per-property `{key:.*}` operations. `PATCH` calls `MergeMetadataFlat` — matches the partial-update semantics PR B needs. `/api/metadata/{hashId}` stays as the editor-internal surface (no PATCH, no per-property routes) but is not the migration target. |
| **SSE event-id contract?** | **Opaque to consumers** | Today it's the Redis Stream entry ID (`<ms>-<seq>`); consumers MUST treat as opaque so the backing store can be swapped without breaking the wire contract. Documented in the SSE endpoint comment and in this doc. | | **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. |
@@ -591,7 +629,7 @@ 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. | | `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`,
@@ -709,7 +747,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
+72 -58
View File
@@ -3,14 +3,15 @@
## 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 (an embedded SQLite
file; 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 storage data model, API design, the SSE event surface, and the integration
contract with other MetaMesh services. contract with other MetaMesh services.
For the on-disk Redis layout, see For the on-disk key layout, 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).
@@ -31,14 +32,15 @@ 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** — an embedded SQLite file
(`/meta-core/db/meta-core.db`) holding metadata and the event streams.
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)
@@ -53,8 +55,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 +64,17 @@ 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 (db/meta-core.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) │
│ services/*.json │ │ services/*.json │
│ mounts/{mounts.json, errors/} │ │ mounts/{mounts.json, errors/} │
│ watchers.json │ │ watchers.json │
@@ -152,7 +154,7 @@ Container startup
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ supervisord starts: │ │ supervisord starts: │
│ redis → nginx → rclone → │ │ nginx → rclone → │
│ meta-core Go binary │ │ meta-core Go binary │
└──────────────────────────────┘ └──────────────────────────────┘
``` ```
@@ -167,10 +169,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,18 +195,18 @@ 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:54-61` calls
`storage.EnsureSchemaVersion`. The sentinel lives in Redis at `storage.EnsureSchemaVersion`. The sentinel lives in the store at
`meta-core:schema-version` (`internal/storage/schema_version.go`). The `meta-core:schema-version` (`internal/storage/schema_version.go`). The
gate refuses to boot when the existing Redis layout predates the gate refuses to boot when the existing store layout predates the
UUID-rooted schema, so legacy `file:midhash256:*` data can't leak into UUID-rooted schema, so legacy `file:midhash256:*` data can't leak into
the current code paths. In alpha there is no automated migration — the current code paths. In alpha there is no automated migration —
operators wipe with `pnpm run clean:all` and restart. operators wipe with `pnpm run clean:all` and restart.
@@ -216,20 +219,20 @@ operators wipe with `pnpm run clean:all` and restart.
``` ```
┌────────────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────────────┐
│ METADATA (Redis) │ │ METADATA (embedded SQLite, kv table) │
│ │ │ │
│ Root: opaque UUIDv7 (Crockford Base32; ULID layout, 26 chars) │ │ Root: opaque UUIDv7 (Crockford Base32; ULID layout, 26 chars) │
│ Storage: flat string keys │ │ Storage: flat string keys │
│ file:<uuid>/<property> → string value │ │ file:<uuid>/<property> → string value │
│ file:<uuid>/cids/<bareCid> → "true" (key-set) │ │ file:<uuid>/cids/<bareCid> → "true" (key-set) │
│ file:<uuid>/duplicates → SET of paths │ │ dup:<uuid>/<path> → "" (one per path) │
│ │ │ │
│ No stored canonical_cid — derived by rank on read. │ │ No stored canonical_cid — derived by rank on read. │
│ │ │ │
│ Aliases (reverse index, one Redis STRING each): │ │ Aliases (reverse index, one key each): │
│ cid:<bareCid> → <uuid> │ │ cid:<bareCid> → <uuid> │
│ │ │ │
│ Index: file:__index__ → SET of all UUIDs │ │ Index: idx:roots/<uuid> → "" (one per root) │
│ │ │ │
│ Mintable without a local file (remote / deleted entries OK). │ │ Mintable without a local file (remote / deleted entries OK). │
│ Resolvable by any registered CID in O(1). │ │ Resolvable by any registered CID in O(1). │
@@ -276,9 +279,8 @@ Bootstrap / discovery
GET /status GET /status
GET /leader GET /leader
GET /urls GET /urls
GET /services (alias: /api/services) GET /neighbors (alias: /api/neighbors)
GET /services/{name} GET /api/stats
GET /services/cleanup/stats
Metadata (primary surface for other services) Metadata (primary surface for other services)
GET /meta GET /meta
@@ -301,6 +303,7 @@ CID-addressed (public, auth-bypassed at the perimeter)
Editor / KV / schema / snapshot Editor / KV / schema / snapshot
GET /api/metadata/hash-ids GET /api/metadata/hash-ids
GET /api/files/tuples
GET /api/metadata/list GET /api/metadata/list
POST /api/metadata/search POST /api/metadata/search
POST /api/metadata/batch POST /api/metadata/batch
@@ -317,7 +320,7 @@ Editor / KV / schema / snapshot
POST /api/snapshot/import POST /api/snapshot/import
POST /api/snapshot/wipe POST /api/snapshot/wipe
SSE event streams (HTTP-mirror of Redis Streams) 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,8 +340,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` —
were removed with the 2026-10 SQLite switch; they had been run once on
watch.nsl.sh beforehand.)
WebDAV WebDAV
/webdav/... (GET, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND) /webdav/... (GET, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND)
@@ -366,12 +371,16 @@ 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.
@@ -380,9 +389,9 @@ 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 `meta:events` entries are appended by the storage engine in the same
`MetaPublisher` uses one to drain keyspace notifications — but it is not transaction as the change they describe; the Redis-era keyspace-notification
the external contract. hop through `MetaPublisher` is gone.
### WebDAV URLs ### WebDAV URLs
@@ -412,7 +421,7 @@ 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) |
| WebDAV | `golang.org/x/net/webdav` | | WebDAV | `golang.org/x/net/webdav` |
### Repo layout ### Repo layout
@@ -425,12 +434,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 │ ├── kv/ SQLite kv engine + 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 │ ├── snapshot/ snapshot export / import / wipe
│ ├── storage/ Redis wrapper + UUID + CID reverse index + schema gate │ ├── storage/ flat-key records + search + 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
@@ -451,21 +460,22 @@ 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. Open the SQLite store (META_CORE_DB_PATH or /meta-core/db/meta-core.db)
4. EnsureSchemaVersion (abort if Redis layout is stale) 4. EnsureSchemaVersion (abort if the store layout is stale)
5. Start service discovery + dead-service cleaner 5. Start service discovery + dead-service cleaner
6. NewServer + Server.Start (boots watcher, mounts, WebDAV, API) 6. NewServer + Server.Start (boots watcher, mounts, WebDAV, API)
7. On first connection: start MetaPublisher + schema Indexer, 7. EnsureCounters (one-time build of the derived counters
republish existing metadata onto meta:events on a store that predates them)
8. Wait for SIGINT/SIGTERM 8. Rebuild FTS rows in the background if the FTS table is empty
9. Shutdown in reverse order 9. Wait for SIGINT/SIGTERM
10. Shutdown in reverse order
``` ```
### 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`.
--- ---
@@ -473,15 +483,16 @@ 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 The SQLite file runs in WAL mode 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. A record write — fields, root index, CID aliases,
lost across restarts. FTS row and its events — is one transaction, so a crash never leaves a
half-written record.
### Container crash ### Container crash
@@ -514,7 +525,10 @@ 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` | SQLite database file. 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_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. |
@@ -533,7 +547,7 @@ 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`) | Metadata store (SQLite). Replaced `db/redis/` in 2026-10. |
| `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. |
@@ -549,5 +563,5 @@ 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. UUID-rooted storage with CID aliasing
treats all content hashes symmetrically and makes any-CID resolution treats all content hashes symmetrically and makes any-CID resolution
O(1). API-mediated access ensures no other service ever needs to speak a single lookup. API-mediated access ensures no other service ever touches
Redis. the store.
+176 -84
View File
@@ -1,6 +1,6 @@
# 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 file metadata in its store. This document covers the
on-disk shape (UUID-rooted flat string keys, CID reverse index, on-disk shape (UUID-rooted flat string keys, CID reverse index,
per-root sets) and the schema-version gate. It does **not** enumerate per-root sets) and the schema-version gate. It does **not** enumerate
field semantics — that's the job of the repo-root field semantics — that's the job of the repo-root
@@ -12,40 +12,83 @@ reverse index), see [`uuid-rooted-metadata.md`](uuid-rooted-metadata.md).
For the external-access contract (HTTP-only, SSE for events), see For the external-access contract (HTTP-only, SSE for events), see
[`api-mediated-access.md`](api-mediated-access.md). [`api-mediated-access.md`](api-mediated-access.md).
> **Storage engine (since 2026-10): an embedded SQLite file, not Redis.**
> Until 2026-10 these keys lived in a Redis server spawned inside the
> container. They now live in one string→string table of an embedded SQLite
> database (pure-Go driver `modernc.org/sqlite`, CGO off). The **key names
> did not change** — only the Redis *types* that some bookkeeping used (SETs,
> HASHes, Streams) were flattened into plain keys, listed below. Holding
> everything in RAM had cost ~6.6 GB on watch.nsl.sh's 169k records (Redis
> plus the API's in-memory search copy) and caused repeated OOM kills.
## Quick summary ## Quick summary
- **One table**: `kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID`,
schema-free — a new metadata field is just a new key, never a migration.
File: `/meta-core/db/meta-core.db` (override `META_CORE_DB_PATH`), WAL
mode, `synchronous=NORMAL`.
- **Roots are UUIDv7** encoded as 26-char Crockford Base32 (ULID layout), - **Roots are UUIDv7** encoded as 26-char Crockford Base32 (ULID layout),
not content hashes. Examples in this doc use the placeholder not content hashes. Examples in this doc use the placeholder
`01JKR8XW5T4QMV7AHNJ2DEFGHK`. `01JKR8XW5T4QMV7AHNJ2DEFGHK`.
- **Properties are flat Redis STRING keys**, one per leaf: - **Properties are flat string keys**, one per leaf:
`file:<uuid>/<property/path>` → string value. *Not* Redis Hashes. `file:<uuid>/<property/path>` → string value. A record is exactly the
key range `[file:<uuid>/, file:<uuid>0)` — there is no per-record field
list.
- **CIDs are reverse-index aliases**: `cid:<bareCid>` → `<uuid>` - **CIDs are reverse-index aliases**: `cid:<bareCid>` → `<uuid>`
(a Redis STRING per known CID; no algorithm prefix — the multicodec (one key per known CID; no algorithm prefix — the multicodec
already carries it). already carries it).
- **Per-root sets**: `file:<uuid>/cids/<bareCid>` (a *key-set*: one STRING - **Per-root sets**: `file:<uuid>/cids/<bareCid>` (a *key-set*: one key
`"true"` per known CID, midhash included), `file:<uuid>/duplicates` `"true"` per known CID, midhash included), `dup:<uuid>/<path>` (alternate
(alternate paths with the same content), plus `file:__index__` (every UUID). paths with the same content), plus `idx:roots/<uuid>` (every UUID).
There is **no stored `canonical_cid`** — it is derived by rank on read. There is **no stored `canonical_cid`** — it is derived by rank on read.
- **A schema-version sentinel** (`meta-core:schema-version`) refuses to - **A schema-version sentinel** (`meta-core:schema-version`) refuses to
boot against legacy data. boot against legacy data.
Implementation lives in `internal/storage/client.go`, Implementation lives in `internal/kv/kv.go` (engine),
`internal/storage/cid_resolution.go`, `internal/storage/uuid.go`, and `internal/kv/stream.go` (event streams), `internal/storage/client.go`,
`internal/storage/schema_version.go`. `internal/storage/search.go`, `internal/storage/cid_resolution.go`,
`internal/storage/uuid.go`, and `internal/storage/schema_version.go`.
## Key structure ## Key structure
Everything is one string→string table. The full layout:
| Key | Value | Notes |
|---|---|---|
| `file:<rid>/<field>` | field value | a record property — the **only** thing under `file:` |
| `cid:<cid>` | `<rid>` | reverse index |
| `meta-core:schema-version` | `"2"` | schema gate |
| `idx:roots/<rid>` | `""` | set of every root (was the Redis SET `file:__index__`) |
| `idx:fts/<rid>` | FTS rowid | the record's row in the FTS table |
| `idx:count/roots` | integer | number of roots — search's `total`, `/api/stats` records (v1.2.0) |
| `idx:schema/<field>\x1f<keyHint>\x1f<prim>\x1f<hint>` | integer | per-field value-type counts behind `/api/schema` (v1.2.0) |
| `idx:count/ready` | `"1"` | marks the counters as built (absent → one-time build at boot) |
| `dup:<rid>/<path>` | `""` | a duplicate path (was the SET `file:<rid>/duplicates`) |
| `udl:rec:<uid>/<cid>/<key>` | JSON `{record,version,ts,value?}` | User Data Layer entry (was a HASH) |
| `udl:idx:…<set>\x1f<member>` | `""` | UDL index sets, one key per member |
| `ev:<stream>/<ms13>-<seq10>` | JSON of the entry's fields | `meta:events` / `file:events` entries (were Redis Streams) |
| `evhwm:<stream>` | last issued ID | stream high-water mark |
| `btmagnet:*`, `searchcov:*`, `nzb:*` | — | written by other services via `/api/kv/value`; unchanged |
Keeping bookkeeping **out of `file:`** is load-bearing: the `file:` range is
exactly the records, so the field index covers only real fields and every
change under `file:` is a real field change worth publishing on
`meta:events`. The per-record `file:<rid>/__fields__` SET and the
`file:__dirty__` set of the Redis era no longer exist. Roots are split from
the field at the **first** `/`, so CID-rooted and UUID-rooted records both
parse.
### Roots ### Roots
``` ```
file:<uuid>/<property/path> → Redis STRING (always) file:<uuid>/<property/path> → string (always)
``` ```
UUIDs are produced by `storage.NewUUID()` (`internal/storage/uuid.go`): UUIDs are produced by `storage.NewUUID()` (`internal/storage/uuid.go`):
UUIDv7 → 26-char Crockford Base32 (skips I/L/O/U; ULID-compatible). The UUIDv7 → 26-char Crockford Base32 (skips I/L/O/U; ULID-compatible). The
time-sortable prefix lets you `KEYS file:01JKR8*` to find roots minted time-sortable prefix means roots minted in a given window sort together
in a given window, which restored some of the debug-ability lost when (`k >= 'file:01JKR8' AND k < 'file:01JKR9'`), which restored some of the
midhash256 stopped being the root. debug-ability lost when midhash256 stopped being the root.
Properties use `/` as the path separator. Nested objects in source code Properties use `/` as the path separator. Nested objects in source code
are flattened to flat keys on write and reconstructed on read. Examples: are flattened to flat keys on write and reconstructed on read. Examples:
@@ -62,7 +105,7 @@ file:01JKR8XW5T4QMV7AHNJ2DEFGHK/tmdb/poster = "bafkreih...
### Reverse-index aliases (CIDs) ### Reverse-index aliases (CIDs)
Every CID known for a file is registered as its own Redis STRING, keyed by Every CID known for a file is registered as its own key, keyed by
the **bare CID** — no algorithm prefix. A CIDv1 already encodes its hash the **bare CID** — no algorithm prefix. A CIDv1 already encodes its hash
function in the multicodec, so naming it again in the key would be function in the multicodec, so naming it again in the key would be
redundant (and would let the same digest be indexed under two spellings): redundant (and would let the same digest be indexed under two spellings):
@@ -78,13 +121,12 @@ cid:bafkreih5kapbjzq... → 01JKR8XW5T4QMV7AHNJ2DEFGHK
cid:bagacbabaec7v3... → 01JKR8XW5T4QMV7AHNJ2DEFGHK cid:bagacbabaec7v3... → 01JKR8XW5T4QMV7AHNJ2DEFGHK
``` ```
The reverse index makes `GET /api/meta/<cid>` an O(1) lookup for any The reverse index makes `GET /api/meta/<cid>` a primary-key lookup for any
registered CID, independent of which hash algorithm — there is no primary. registered CID, independent of which hash algorithm — there is no primary.
The auto-alias hook (`maybeAddAliasFromFieldLocked` in The record write path (`writeFieldsTx` in `internal/storage/client.go`, used
`internal/storage/cid_resolution.go`, called from `SetProperty`, by `SetProperty`, `SetMetadataFlat` and `MergeMetadataFlat`) registers the
`SetMetadataFlat` and `MergeMetadataFlat`) registers the alias for any alias for any field whose name starts with `cids/`, in the same transaction,
field whose name starts with `cids/`, so writers emit key-set members so writers emit key-set members without knowing the reverse index exists.
without knowing the reverse index exists.
> ⚠ The hook matches **only** the `cids/` prefix. A legacy `cid_*` or > ⚠ The hook matches **only** the `cids/` prefix. A legacy `cid_*` or
> `midhash256` *named* field written to a root is stored verbatim and is > `midhash256` *named* field written to a root is stored verbatim and is
@@ -95,12 +137,13 @@ without knowing the reverse index exists.
### Per-root sets ### Per-root sets
``` ```
file:<uuid>/cids/<bareCid> → STRING "true" — one key per known CID (a key-set, not a Redis SET) file:<uuid>/cids/<bareCid> → "true" — one key per known CID (a key-set)
file:<uuid>/duplicates → Redis SET of alternate paths with same content dup:<uuid>/<path> → "" — one key per alternate path with same content
``` ```
`cids` is a **key-set**: one flat `file:<uuid>/cids/<cid>` STRING per member, `cids` is a **key-set**: one flat `file:<uuid>/cids/<cid>` key per member,
which keeps it conflict-free to merge across peers (a SET would not be). which keeps it conflict-free to merge across peers (a single set-valued
field would not be).
The record's own address (the midhash) is just the `0x1000` member — it has The record's own address (the midhash) is just the `0x1000` member — it has
no separate field. no separate field.
@@ -114,61 +157,88 @@ was removed with the flat `cid_*` keys.
### Global index ### Global index
``` ```
file:__index__ → Redis SET of all UUIDs idx:roots/<uuid> → "" (one key per root; was the Redis SET file:__index__)
``` ```
`internal/storage/client.go::GetAllHashIDs` reads this set to enumerate `internal/storage/client.go::GetAllHashIDs` range-reads `idx:roots/` to
every file without scanning the keyspace. enumerate every file without scanning the records.
```bash ### Search structures (derived)
SMEMBERS file:__index__ # all UUIDs
SCARD file:__index__ # total file count Two derived structures are maintained on top of `kv`, in the same
SISMEMBER file:__index__ <uuid> transaction as every record write. Both are rebuildable from the kv rows and
``` never authoritative (`internal/storage/search.go`):
- **`kv_field`** — a partial expression index
`ON kv(lower(substr(k,instr(k,'/')+1)), lower(v)) WHERE k>='file:' AND k<'file;' AND instr(k,'/')>0`.
It answers "which records have field F with a value like V" for **any**
field, including ones invented tomorrow — the index is over the key itself.
- **`fts`** — an FTS5 trigram table `fts(rid UNINDEXED, hay)` holding one row
per record: its search haystack plus its rid (`idx:fts/<rid>` points at the
row). Trigrams make every 3+ character substring indexable.
`POST /api/metadata/search` uses SQL to build a superset of candidates, then
verifies each with the unchanged Go matchers (`recordMatchesFilter`,
`matchAllTokens`), so semantics are identical to the old in-memory index.
Results still come in hashID order and are fresh immediately (no refresh
lag). This replaced the API's in-memory `searchIndex` (60 s refresh, ~30 KB
RAM per record).
### Schema-version sentinel ### Schema-version sentinel
``` ```
meta-core:schema-version → STRING (currently "2") meta-core:schema-version → string (currently "2")
``` ```
Set at first boot, checked on every subsequent boot Set at first boot, checked on every subsequent boot
(`internal/storage/schema_version.go`). If the sentinel is missing but (`internal/storage/schema_version.go`). If the sentinel is missing but
`file:__index__` is non-empty, the gate refuses to boot (legacy data). the root index is non-empty, the gate refuses to boot (legacy data).
If the sentinel is present but doesn't match `SchemaVersion`, the gate If the sentinel is present but doesn't match `SchemaVersion`, the gate
also refuses to boot. Alpha-stage policy: operators wipe and restart; also refuses to boot. Alpha-stage policy: operators wipe and restart;
there is no automated migration. there is no automated migration.
## Why flat STRING keys? ## Why flat string keys?
The store does not use Redis Hashes. The reasons are: The store keeps one flat key per leaf rather than a document or a
per-record row. The reasons are:
1. **Field-level keyspace notifications.** Redis publishes 1. **Schema-free.** A plugin inventing a field writes a new key; nothing
`__keyspace@0__:<key>` on every SET/DEL of a STRING key. With Hashes declares it, and the table never needs a migration. The field index
you'd only get one notification on the hash itself. meta-core's covers it automatically.
`MetaPublisher` (`internal/events/meta_publisher.go`) consumes these 2. **Conflict-free merging across peers.** Key-sets (`cids/<cid>`, and the
notifications and publishes structured events to the `meta:events` flat fields in general) merge by union of keys — no read-modify-write of
Redis Stream. a shared value, nothing to reconcile.
2. **Selective updates without read-modify-write.** A plugin writing 3. **Selective updates without read-modify-write.** A plugin writing
`tmdb/poster` doesn't have to read the rest of the document. `tmdb/poster` doesn't have to read the rest of the document.
3. **Compatibility with the CID alias hooks.** Field writes can trigger 4. **Compatibility with the CID alias hooks.** Field writes can trigger
alias-maintenance work in `SetProperty` without the plugin caring. alias-maintenance work in the write path without the plugin caring.
(Under Redis there was a fourth reason — field-level keyspace notifications
fed `meta:events`. That is obsolete: events are now written by the storage
engine itself, see [Event streams](#event-streams).)
## Reads and writes ## Reads and writes
All writes go through **one writer goroutine** with group commit: concurrent
writes share one SQLite transaction, each under its own SAVEPOINT so a
failure rolls back alone. A record write — fields + root index + CID aliases
+ FTS row + events — is atomic. (Measured ~835 records/s with 16 concurrent
writers of 40-field records.) Reads use a separate connection pool and see
the last committed state.
The Go API in `internal/storage/client.go`: The Go API in `internal/storage/client.go`:
| Method | Behaviour | | Method | Behaviour |
|---|---| |---|---|
| `GetMetadataFlat(uuid)` | `SCAN` for `file:<uuid>/*`, `MGET` the values, return `map[string]string`. | | `GetMetadataFlat(uuid)` | Range-read `[file:<uuid>/, file:<uuid>0)`, return `map[string]string`. |
| `SetMetadataFlat(uuid, m)` | Pipeline `SET` per field; `SADD file:__index__`; auto-register an alias for every `cids/<cid>` member. | | `SetMetadataFlat(uuid, m)` | Put each field; add `idx:roots/<uuid>`; auto-register an alias for every `cids/<cid>` member; refresh the FTS row. One transaction. |
| `MergeMetadataFlat(uuid, m)` | Like `SetMetadataFlat` but doesn't pre-clear; used by `PATCH /meta/{hash}`. | | `MergeMetadataFlat(uuid, m)` | Like `SetMetadataFlat` but doesn't pre-clear; used by `PATCH /meta/{hash}`. |
| `GetProperty(uuid, field)` | `GET file:<uuid>/<field>`. | | `GetProperty(uuid, field)` | Get `file:<uuid>/<field>`. |
| `SetProperty(uuid, field, value)` | `SET file:<uuid>/<field>`; auto-register alias if the field is a CID. | | `SetProperty(uuid, field, value)` | Put `file:<uuid>/<field>`; auto-register alias if the field is a CID. |
| `DeleteRoot(uuid)` | `SCAN` + `DEL` for `file:<uuid>/*`, unmap every reverse-index entry. | | `DeleteRoot(uuid)` | Delete the `file:<uuid>/` range, its duplicates, search row and index membership; unmap every reverse-index entry. |
| `Mint(filePath, size, mtimeNano)` | `NewUUID()` + write `filePath/sizeByte/mtimeNano` + add to index. | | `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. | | `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). | | `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}`. | | `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)
@@ -196,7 +266,7 @@ Rules:
## Plugin contribution flow ## Plugin contribution flow
Plugins don't talk to Redis. They write metadata via meta-core's HTTP Plugins don't talk to the store directly. They write metadata via meta-core's HTTP
API (`PUT /meta/{hash}/{key...}` or `PATCH /meta/{hash}`). Each plugin API (`PUT /meta/{hash}/{key...}` or `PATCH /meta/{hash}`). Each plugin
writes under a prefix: writes under a prefix:
@@ -215,12 +285,32 @@ deliberately does not duplicate that registry — it would drift.
## Event streams ## Event streams
Two Redis Streams are produced inside meta-core: Two event streams are kept inside the store, as rows
`ev:<stream>/<ms13>-<seq10>` (zero-padded so key order is ID order) plus a
high-water mark `evhwm:<stream>` (`internal/kv/stream.go`):
| Stream | Producer | Contents | | Stream | Producer | Contents |
|---|---|---| |---|---|---|
| `file:events` | `internal/watcher` dispatcher | `add` / `change` / `delete` / `rename` / `reset` events for files on the `/files` volume. | | `file:events` | `internal/watcher` dispatcher | `add` / `change` / `delete` / `rename` / `reset` events for files on the `/files` volume. |
| `meta:events` | `internal/events.MetaPublisher` | Field-level metadata mutations derived from Redis keyspace notifications. | | `meta:events` | the storage engine (`internal/kv`) | `{type: set\|del, key, ts}` for every Put/Delete of a `file:` key, appended **in the same transaction** as the change. |
`set` is emitted only for a **real** change: a Put of the value a key
already holds is a no-op — no row write, no index churn, no event (v1.2.0;
Redis published `set` even then, and with gateway hits re-persisted on
every repeated query most writes were exactly that). `del` is emitted only
when the key existed. Retention is ~100k entries, trimmed every 10k appends.
There is **no boot-time republish** (v1.2.0). A consumer that wants the whole
current state asks `/api/events/meta` for `Last-Event-ID: 0` / `0-0` and gets
a **snapshot**: one `set` per existing record key, streamed from the kv
range, then the live tail from the stream head captured before the snapshot
(only the last snapshot event carries an `id:`, so a consumer that drops
mid-snapshot gets a fresh one). The old republish wrote every field into a
stream that retains only ~100k entries, so a consumer replaying it later
never saw most records — and it cost ~6 minutes on every boot.
The Redis-era path — keyspace notifications drained by `MetaPublisher`
through a PSUBSCRIBE / consumer-group hop — is gone (2026-10).
External consumers do **not** read these streams directly. They subscribe External consumers do **not** read these streams directly. They subscribe
via SSE: via SSE:
@@ -228,51 +318,50 @@ via SSE:
- `GET /api/events/files` — mirror of `file:events` - `GET /api/events/files` — mirror of `file:events`
- `GET /api/events/meta` — mirror of `meta:events` - `GET /api/events/meta` — mirror of `meta:events`
The SSE wire contract uses `Last-Event-ID` for resume (the opaque The SSE wire contract uses `Last-Event-ID` for resume. IDs keep the Redis
`<ms>-<seq>` Redis Stream entry ID). Heartbeats are SSE comments `<ms>-<seq>` form so cursors persisted by meta-sort / meta-share /
meta-stremio across the switch still compare. Heartbeats are SSE comments
(`:keep-alive\n\n`) every 30s of silence. If retention has trimmed past (`:keep-alive\n\n`) every 30s of silence. If retention has trimmed past
a client's cursor, the handler emits one `event: gap` payload before a client's cursor, the handler emits one `event: gap` payload before
resuming. See `internal/api/sse_events.go` and resuming. See `internal/api/sse_events.go` and
[`api-mediated-access.md`](api-mediated-access.md) for the full [`api-mediated-access.md`](api-mediated-access.md) for the full
contract. 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
```bash The database path is `/meta-core/db/meta-core.db` in the image default and
# Roots on the store app; dev stacks use `/meta-core-db/meta-core.db` (a named
docker exec metacore-app redis-cli SMEMBERS 'file:__index__' volume — the `/meta-core` bind is 9p on Windows dev boxes, where SQLite WAL
docker exec metacore-app redis-cli SCARD 'file:__index__' is unsafe). Always open it **read-only**; writes must go through the API.
docker exec metacore-app redis-cli SRANDMEMBER 'file:__index__'
# Full document for one root (flat scan) ```bash
docker exec metacore-app redis-cli --scan --pattern 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/*' DB=/meta-core/db/meta-core.db # dev stack: /meta-core-db/meta-core.db
Q() { docker exec metacore-app sqlite3 -readonly "$DB" "$1"; }
# Roots
Q "SELECT count(*) FROM kv WHERE k >= 'idx:roots/' AND k < 'idx:roots0'"
Q "SELECT substr(k,11) FROM kv WHERE k >= 'idx:roots/' AND k < 'idx:roots0' LIMIT 10"
# Full document for one root
Q "SELECT k,v FROM kv WHERE k >= 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/' AND k < 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK0'"
# One property # One property
docker exec metacore-app redis-cli GET 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/duration' Q "SELECT v FROM kv WHERE k = 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/duration'"
# Confirm keys are STRING (not HASH)
docker exec metacore-app redis-cli TYPE 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/filePath'
# string
# Reverse-index lookup (bare CID — no algorithm prefix) # Reverse-index lookup (bare CID — no algorithm prefix)
docker exec metacore-app redis-cli GET 'cid:bafkreih...' # → uuid Q "SELECT v FROM kv WHERE k = 'cid:bafkreih...'" # → uuid
# The cids key-set (one STRING per member, NOT a Redis SET — SMEMBERS won't work) # The cids key-set
docker exec metacore-app redis-cli --scan --pattern 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids/*' Q "SELECT k FROM kv WHERE k >= 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids/' AND k < 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids0'"
# canonical_cid is NOT stored. Derive it via the API instead: # 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' curl -k https://metacore-dev.localhost:8083/api/meta/<cid> | jq '.canonical_cid, .cids'
# Schema-version sentinel # Schema-version sentinel
docker exec metacore-app redis-cli GET 'meta-core:schema-version' Q "SELECT v FROM kv WHERE k = 'meta-core:schema-version'"
# Streams (server-side; external consumers should use SSE) # Streams (server-side; external consumers should use SSE)
docker exec metacore-app redis-cli XRANGE 'file:events' - + COUNT 10 Q "SELECT k,v FROM kv WHERE k >= 'ev:meta:events/' AND k < 'ev:meta:events0' ORDER BY k DESC LIMIT 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>' # CID → document
@@ -286,12 +375,15 @@ curl -k 'https://metacore-dev.localhost:8083/api/kv/info' # KV stats
| Component | Path | | Component | Path |
|---|---| |---|---|
| Redis client + flat-key ops | `internal/storage/client.go` | | Storage engine (SQLite kv table, writer, group commit) | `internal/kv/kv.go` |
| Event streams as kv rows | `internal/kv/stream.go` |
| Flat-key record ops | `internal/storage/client.go` |
| Search (field index + FTS) | `internal/storage/search.go` |
| UUIDv7 minting | `internal/storage/uuid.go` | | UUIDv7 minting | `internal/storage/uuid.go` |
| CID reverse index + aliases + canonical-CID reconciliation | `internal/storage/cid_resolution.go` | | CID reverse index + aliases + canonical-CID derivation | `internal/storage/cid_resolution.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` | | `meta:events` snapshot for `0`/`0-0` | `internal/api/sse_events.go` (`streamSnapshot`) |
| Keyspace-notification → `meta:events` publisher | `internal/events/meta_publisher.go` | | Derived counters (root count, per-field schema) | `internal/storage/client.go` (`onChange`), `internal/schema/indexer.go` |
| Watcher → `file:events` dispatcher | `internal/watcher/dispatcher.go` | | Watcher → `file:events` dispatcher | `internal/watcher/dispatcher.go` |
| SSE event mirror | `internal/api/sse_events.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) |
+9 -2
View File
@@ -20,6 +20,12 @@
> `cid:<bareCid>` → uuid. A CIDv1's multicodec already names its hash > `cid:<bareCid>` → uuid. A CIDv1's multicodec already names its hash
> function, so the `<algo>:` prefix was redundant. > function, so the `<algo>:` prefix was redundant.
> >
> 3. **The store is no longer Redis (2026-10).** The same keys now live in
> one string→string table of an embedded SQLite file; Redis SETs became
> one key per member (`file:__index__` → `idx:roots/<uuid>`,
> `file:<uuid>/duplicates` → `dup:<uuid>/<path>`). Redis types named
> below are the shape at the time.
>
> Text below that references either shape describes the *proposal*, not the > Text below that references either shape describes the *proposal*, not the
> code. The rank ordering and everything else did ship as written. > code. The rank ordering and everything else did ship as written.
@@ -135,7 +141,7 @@ sole writer is meta-core itself:
| Key | Type (as shipped) | Purpose | | 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. | | `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. | | `duplicates` | Redis SET at the time; since 2026-10 one `dup:<uuid>/<path>` key per path | 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}`. | | ~~`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`) Existing meta-core-owned fields (`filePath`, `size`, `mtime`, `midhash256`)
@@ -148,7 +154,8 @@ 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 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 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 membership. `duplicates` remained a Redis SET (it is leader-local and never
merged across peers). merged across peers) until the 2026-10 SQLite switch, which flattened it to
`dup:<uuid>/<path>` keys.
### Canonical CID selection ### Canonical CID selection
+1 -1
View File
@@ -81,7 +81,7 @@ export class SnapshotAPI {
} }
/** /**
* Wipe Redis metadata. Pass scope=metadata. * Wipe all stored metadata. Pass scope=metadata.
*/ */
static async wipe(scope: 'metadata'): Promise<WipeResult> { static async wipe(scope: 'metadata'): Promise<WipeResult> {
const response = await fetch(`${API_BASE}/wipe?scope=${scope}`, { const response = await fetch(`${API_BASE}/wipe?scope=${scope}`, {
+1 -1
View File
@@ -43,7 +43,7 @@ export function SnapshotPanel() {
const runWipe = async () => { const runWipe = async () => {
const ok = window.confirm( const ok = window.confirm(
'This deletes ALL metadata in Redis. Files on disk are not touched, but every manual ' + 'This deletes ALL stored metadata. Files on disk are not touched, but every manual ' +
'edit you made via this editor will be lost. Continue?' 'edit you made via this editor will be lost. Continue?'
); );
if (!ok) return; if (!ok) return;
+11 -7
View File
@@ -1,21 +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
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
+48 -22
View File
@@ -1,36 +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/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
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=
-30
View File
@@ -1,30 +0,0 @@
package api
import (
"net/http"
)
// 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,
})
}
+132
View File
@@ -0,0 +1,132 @@
package api
import (
"encoding/json"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"github.com/metazla/meta-core/internal/watcher"
)
// registerFileRequest is the body of POST /api/files/register.
type registerFileRequest struct {
// Path relative to FILES_PATH, e.g. "plugin/subtitles/ab12….eng.srt".
Path string `json:"path"`
}
type registerFileResponse struct {
Root string `json:"root"`
CID string `json:"cid"`
Created bool `json:"created"`
Path string `json:"path"`
Size int64 `json:"size"`
}
// pluginRelPath normalises a FILES_PATH-relative path and refuses anything
// outside plugin/. Plugin output is the only tree a service may register this
// way: /files/watch is the user's library and belongs to the watcher.
func pluginRelPath(raw string) (string, bool) {
p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "/")
if p == "" {
return "", false
}
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return "", false
}
}
clean := path.Clean(p)
if !strings.HasPrefix(clean, "plugin/") || clean == "plugin/" {
return "", false
}
return clean, true
}
// handleRegisterFile handles POST /api/files/register.
//
// Does for ONE file what the watcher does for every file it scans: midhash256
// it, mint a root carrying filePath/sizeByte/mtimeNano, register the
// cid:<midhash> alias, and append a file:events entry so meta-share's
// ipfs_seed chunks, seeds and provides it.
//
// It exists for cores that run with ENABLE_FILE_WATCHER=false (a meta-watch
// client box). Without it, bytes written under /files/plugin over WebDAV are
// never resolvable by CID and never reach the swarm. Idempotent: the same
// content at the same path returns the existing root with created=false.
func (s *Server) handleRegisterFile(w http.ResponseWriter, r *http.Request) {
if s.storage == nil || !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
var req registerFileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
rel, ok := pluginRelPath(req.Path)
if !ok {
writeError(w, http.StatusBadRequest, "path must be a file under plugin/")
return
}
fullPath := filepath.Join(s.config.FilesPath, filepath.FromSlash(rel))
absFiles, _ := filepath.Abs(filepath.Join(s.config.FilesPath, "plugin"))
absFull, _ := filepath.Abs(fullPath)
if !strings.HasPrefix(absFull, absFiles+string(filepath.Separator)) {
writeError(w, http.StatusBadRequest, "path must be a file under plugin/")
return
}
info, err := os.Stat(fullPath)
if err != nil {
writeError(w, http.StatusNotFound, "file not found")
return
}
if info.IsDir() {
writeError(w, http.StatusBadRequest, "path is a directory, not a file")
return
}
midhash, err := watcher.ComputeMidHash256(fullPath, info.Size())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to hash file")
return
}
root, created, err := s.storage.RegisterFileTuple(fullPath, info.Size(), info.ModTime().UnixNano(), midhash)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if created {
ev := watcher.FileEvent{
Type: watcher.EventTypeAdd,
Path: rel,
Size: info.Size(),
MidHash256: midhash,
Timestamp: watcher.NowMS(),
}
if err := watcher.PublishEvent(s.storage, ev); err != nil {
// The record is written and resolvable; only seeding is late.
writeError(w, http.StatusInternalServerError, "registered, but file event not published: "+err.Error())
return
}
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
writeJSON(w, status, registerFileResponse{
Root: root,
CID: midhash,
Created: created,
Path: rel,
Size: info.Size(),
})
}
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/metazla/meta-core/internal/config"
)
// newRegisterTestServer wires handleRegisterFile to a temp store and a temp
// FILES_PATH. Built directly, not through NewServer, so no watcher runs —
// which is exactly the configuration this route exists for.
func newRegisterTestServer(t *testing.T) (*Server, string) {
t.Helper()
files := t.TempDir()
return &Server{storage: newTestStorage(t), config: &config.Config{FilesPath: files}}, files
}
func writeFixture(t *testing.T, files, rel, body string) string {
t.Helper()
full := filepath.Join(files, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return full
}
func postRegister(t *testing.T, s *Server, path string) (int, registerFileResponse) {
t.Helper()
body, _ := json.Marshal(registerFileRequest{Path: path})
req := httptest.NewRequest(http.MethodPost, "/api/files/register", strings.NewReader(string(body)))
rr := httptest.NewRecorder()
s.handleRegisterFile(rr, req)
var resp registerFileResponse
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
return rr.Code, resp
}
func streamLen(t *testing.T, s *Server) int {
t.Helper()
n, err := s.storage.GetStreamLength("file:events")
if err != nil {
t.Fatalf("stream length: %v", err)
}
return int(n)
}
// 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
// about it on file:events.
func TestRegisterFile_MintsAliasAndPublishesEvent(t *testing.T) {
s, files := newRegisterTestServer(t)
rel := "plugin/subtitles/ab12cd34.eng.srt"
full := writeFixture(t, files, rel, "1\n00:00:01,000 --> 00:00:02,000\nHello\n")
code, resp := postRegister(t, s, rel)
if code != http.StatusCreated || !resp.Created {
t.Fatalf("want 201 created, got %d %+v", code, resp)
}
if !strings.HasPrefix(resp.CID, "b") || resp.Root == "" {
t.Fatalf("want a multibase cid and a root, got %+v", resp)
}
root, err := s.storage.GetByCID(resp.CID)
if err != nil || root != resp.Root {
t.Fatalf("cid alias must resolve to the root: got %q err=%v, want %q", root, err, resp.Root)
}
if fp, _ := s.storage.GetProperty(resp.Root, "filePath"); fp != full {
t.Fatalf("filePath = %q, want %q", fp, full)
}
entries, err := s.storage.XRevRange("file:events", "+", "-", 0)
if err != nil || len(entries) != 1 {
t.Fatalf("want exactly one file:events entry, got %d err=%v", len(entries), err)
}
if vals := entries[0].Values; vals["midhash256"] != resp.CID || vals["path"] != rel {
t.Fatalf("event must carry the midhash and the relative path, got %v", vals)
}
}
// 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).
func TestRegisterFile_IsIdempotent(t *testing.T) {
s, files := newRegisterTestServer(t)
rel := "plugin/subtitles/same.eng.srt"
writeFixture(t, files, rel, "same bytes")
_, first := postRegister(t, s, rel)
code, second := postRegister(t, s, rel)
if code != http.StatusOK || second.Created {
t.Fatalf("second register must be 200 created=false, got %d %+v", code, second)
}
if second.Root != first.Root || second.CID != first.CID {
t.Fatalf("second register must return the same record: %+v vs %+v", second, first)
}
if n := streamLen(t, s); n != 1 {
t.Fatalf("an idempotent re-register must not publish again, stream has %d", n)
}
}
// Only plugin output may be registered here. /files/watch is the user's
// library and belongs to the watcher; traversal must not escape either.
func TestRegisterFile_RejectsPathsOutsidePlugin(t *testing.T) {
s, files := newRegisterTestServer(t)
writeFixture(t, files, "watch/movie.mkv", "x")
for _, p := range []string{
"", "plugin", "plugin/", "watch/movie.mkv", "/watch/movie.mkv",
"plugin/../watch/movie.mkv", "../etc/passwd", "plugin/../../etc/passwd",
} {
if code, _ := postRegister(t, s, p); code != http.StatusBadRequest {
t.Errorf("path %q: want 400, got %d", p, code)
}
}
}
func TestRegisterFile_MissingFileAndDirectory(t *testing.T) {
s, files := newRegisterTestServer(t)
if code, _ := postRegister(t, s, "plugin/subtitles/nope.srt"); code != http.StatusNotFound {
t.Errorf("missing file: want 404, got %d", code)
}
if err := os.MkdirAll(filepath.Join(files, "plugin", "subtitles", "dir"), 0o755); err != nil {
t.Fatal(err)
}
if code, _ := postRegister(t, s, "plugin/subtitles/dir"); code != http.StatusBadRequest {
t.Errorf("directory: want 400, got %d", code)
}
}
+74
View File
@@ -0,0 +1,74 @@
package api
import "net/http"
// fileTupleJSON is one file-backed root on the wire.
type fileTupleJSON struct {
HashID string `json:"hashId"`
FilePath string `json:"filePath"`
SizeByte int64 `json:"sizeByte"`
MtimeNano int64 `json:"mtimeNano"`
}
// FileTuplesResponse is the body of GET /api/files/tuples.
//
// Files is a pointer so the summary form can omit it while the full form
// always carries it — an empty library is `"files": []`, never an absent key,
// which a client could mistake for a meta-core that lacks the rows.
type FileTuplesResponse struct {
Files *[]fileTupleJSON `json:"files,omitempty"`
Count int `json:"count"`
TotalSize int64 `json:"totalSize"`
}
// handleGetFileTuples handles GET /api/files/tuples.
//
// Lists the FILE-BACKED roots — records carrying filePath, sizeByte and
// mtimeNano, i.e. what the watcher or /api/files/register minted — with their
// sizes, plus the count and total. Gateway / cid-rooted records carry no file
// and are not included; neither is a locator record that only merged a cache
// file's metadata (it has no mtimeNano).
//
// It exists so a client never walks every hashId to answer "how many files,
// how big": GetTuplesForAllFiles reads the three fields for every root in
// chunked MGETs server-side (~250 round-trips for 82k roots, no SCAN), where
// the client-side equivalent was one HTTP request per record.
//
// ?summary=1 omits the rows and returns only {count, totalSize}.
func (s *Server) handleGetFileTuples(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
tuples, err := s.storage.GetTuplesForAllFiles()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
summary := r.URL.Query().Get("summary")
withRows := summary == "" || summary == "0" || summary == "false"
resp := FileTuplesResponse{Count: len(tuples)}
var rows []fileTupleJSON
if withRows {
rows = make([]fileTupleJSON, 0, len(tuples))
}
for _, t := range tuples {
resp.TotalSize += t.Size
if withRows {
rows = append(rows, fileTupleJSON{
HashID: t.HashID,
FilePath: t.FilePath,
SizeByte: t.Size,
MtimeNano: t.MtimeNano,
})
}
}
if withRows {
resp.Files = &rows
}
writeJSON(w, http.StatusOK, resp)
}
+108
View File
@@ -0,0 +1,108 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sort"
"testing"
"github.com/metazla/meta-core/internal/storage"
)
func newFileTuplesTestServer(t *testing.T) (*Server, *storage.Client) {
t.Helper()
c := newTestStorage(t)
return &Server{storage: c}, c
}
func getFileTuples(t *testing.T, s *Server, query string) (int, FileTuplesResponse) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/files/tuples"+query, nil)
rr := httptest.NewRecorder()
s.handleGetFileTuples(rr, req)
var resp FileTuplesResponse
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
return rr.Code, resp
}
func mustMint(t *testing.T, c *storage.Client, path string, size, mtime int64) string {
t.Helper()
id, err := c.Mint(path, size, mtime)
if err != nil {
t.Fatalf("mint %s: %v", path, err)
}
return id
}
// Only records that are actually files count: a gateway record and a record
// that merely carries a path + size (no mtimeNano) are both left out.
func TestFileTuples_OnlyFileBackedRoots(t *testing.T) {
s, c := newFileTuplesTestServer(t)
a := mustMint(t, c, "/files/watch/a.mkv", 1000, 1)
b := mustMint(t, c, "/files/watch/b.mkv", 2500, 2)
if err := c.SetMetadataFlat("bagcsgatewayrecord", map[string]string{"title": "Some Release"}); err != nil {
t.Fatal(err)
}
if err := c.SetMetadataFlat("01partialroot", map[string]string{"filePath": "/files/cache/x.mkv", "sizeByte": "999"}); err != nil {
t.Fatal(err)
}
code, resp := getFileTuples(t, s, "")
if code != http.StatusOK {
t.Fatalf("want 200, got %d", code)
}
if resp.Count != 2 || resp.TotalSize != 3500 {
t.Fatalf("want count 2 / totalSize 3500, got %d / %d", resp.Count, resp.TotalSize)
}
if resp.Files == nil || len(*resp.Files) != 2 {
t.Fatalf("want 2 rows, got %+v", resp.Files)
}
got := []string{(*resp.Files)[0].HashID, (*resp.Files)[1].HashID}
want := []string{a, b}
sort.Strings(got)
sort.Strings(want)
if got[0] != want[0] || got[1] != want[1] {
t.Fatalf("want roots %v, got %v", want, got)
}
}
func TestFileTuples_SummaryOmitsRows(t *testing.T) {
s, c := newFileTuplesTestServer(t)
mustMint(t, c, "/files/watch/a.mkv", 1000, 1)
mustMint(t, c, "/files/watch/b.mkv", 2500, 2)
code, resp := getFileTuples(t, s, "?summary=1")
if code != http.StatusOK {
t.Fatalf("want 200, got %d", code)
}
if resp.Files != nil {
t.Fatalf("summary must omit rows, got %d", len(*resp.Files))
}
if resp.Count != 2 || resp.TotalSize != 3500 {
t.Fatalf("want count 2 / totalSize 3500, got %d / %d", resp.Count, resp.TotalSize)
}
}
// An empty library still carries "files": [] — clients treat an absent key as
// "this meta-core cannot list files", so it must not disappear when empty.
func TestFileTuples_EmptyLibraryKeepsFilesKey(t *testing.T) {
s, _ := newFileTuplesTestServer(t)
code, resp := getFileTuples(t, s, "")
if code != http.StatusOK {
t.Fatalf("want 200, got %d", code)
}
if resp.Files == nil || len(*resp.Files) != 0 || resp.Count != 0 {
t.Fatalf("want an empty files array and count 0, got %+v", resp)
}
}
func TestFileTuples_StorageNotConnected(t *testing.T) {
s := &Server{storage: storage.NewClient()}
code, _ := getFileTuples(t, s, "")
if code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", code)
}
}
+35 -47
View File
@@ -14,6 +14,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/leader" "github.com/metazla/meta-core/internal/leader"
"github.com/metazla/meta-core/internal/meshdisco"
) )
// contentTypeByExt maps file extensions to MIME types // contentTypeByExt maps file extensions to MIME types
@@ -547,62 +548,49 @@ func (s *Server) handleGetFileByCID(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file) http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file)
} }
// handleListServices handles GET /services and GET /api/services // handleListNeighbors handles GET /neighbors and GET /api/neighbors.
// Supports optional ?current=<service-name> query parameter //
func (s *Server) handleListServices(w http.ResponseWriter, r *http.Request) { // Beacon v2 (docs/project-architecture/beacon-v2.md). The list is this node's
services, err := s.discovery.DiscoverAll() // in-memory view of the UDP group, so it works across stacks that share a
if err != nil { // network but no volume. Instances of one name collapse to the most recent
writeError(w, http.StatusInternalServerError, err.Error()) // (one row per service, the nav menu's shape); `?all=1` returns every
return // instance, `?cap=<pattern>` keeps only nodes with a matching capability.
} func (s *Server) handleListNeighbors(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{ response := map[string]interface{}{
"services": services, "current": s.config.ServiceName,
"count": len(services), "neighbors": []interface{}{},
} "count": 0,
// Add 'current' field if query parameter is provided
current := r.URL.Query().Get("current")
if current != "" {
response["current"] = current
} }
if s.mesh == nil {
// Discovery off or failed to bind. Report empty rather than 503 —
// the nav is decoration and callers already treat [] as "hide".
response["enabled"] = false
writeJSON(w, http.StatusOK, response) writeJSON(w, http.StatusOK, response)
}
// handleGetService handles GET /services/{name}
func (s *Server) handleGetService(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
if name == "" {
writeError(w, http.StatusBadRequest, "service name is required")
return return
} }
service, err := s.discovery.Discover(name) var list []meshdisco.Neighbor
if err != nil { if r.URL.Query().Get("all") != "" {
writeError(w, http.StatusInternalServerError, err.Error()) list = s.mesh.Neighbors()
return } else {
list = s.mesh.NeighborsByName()
}
if pattern := r.URL.Query().Get("cap"); pattern != "" {
kept := list[:0]
for _, nb := range list {
if nb.Matches(pattern) {
kept = append(kept, nb)
}
}
list = kept
} }
if service == nil { response["enabled"] = true
writeError(w, http.StatusNotFound, "service not found") response["neighbors"] = list
return response["count"] = len(list)
} response["self"] = s.mesh.Self()
writeJSON(w, http.StatusOK, response)
writeJSON(w, http.StatusOK, service)
}
// handleCleanupStats handles GET /services/cleanup/stats
func (s *Server) handleCleanupStats(w http.ResponseWriter, r *http.Request) {
if s.cleaner == nil {
writeError(w, http.StatusServiceUnavailable, "cleaner not initialized")
return
}
stats := s.cleaner.Stats()
writeJSON(w, http.StatusOK, stats)
} }
// writeJSON writes a JSON response // writeJSON writes a JSON response
+272 -42
View File
@@ -3,10 +3,14 @@ package api
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"log"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/metazla/meta-core/internal/identity" "github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/storage"
) )
// identityStatusResponse is the shape of GET /api/identity. The private key // identityStatusResponse is the shape of GET /api/identity. The private key
@@ -58,12 +62,40 @@ type identityImportResponse struct {
Created bool `json:"created"` Created bool `json:"created"`
} }
// identityRevealRequest asks for an existing account's private key back. The // identityChallengeRequest asks for a one-shot challenge to sign.
// key was shown once at generate; without a way to see it again, a client with type identityChallengeRequest struct {
// a real sign-out has no recovery path for a user who never saved it. UID string `json:"uid"`
Action string `json:"action"`
}
type identityChallengeResponse struct {
Challenge string `json:"challenge"`
ExpiresAt int64 `json:"expiresAt"`
}
// identityProof is the proof-of-possession carried by reveal and delete: the
// challenge text as issued, and a signature over it by the account's key.
//
// The signature is produced by whoever holds the key — the browser, locally.
// It is NOT obtainable from POST /api/identity/sign, which refuses any payload
// in the challenge domain; that refusal is what makes this proof mean
// something on a node that stores every private key in plaintext.
type identityProof struct {
Challenge string `json:"challenge"`
Signature string `json:"signature"`
}
// identityRevealRequest asks for an existing account's private key back.
//
// Gated on proof-of-possession, which makes it a "does this node hold the same
// key I do" check rather than the recovery path it once was. That is the
// deliberate consequence of the rule this file enforces: the private key, and
// nothing else, is authority over the account. An operator with root on the box
// can still read the file; an operator with only the API cannot.
type identityRevealRequest struct { type identityRevealRequest struct {
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
UID string `json:"uid"` UID string `json:"uid"`
identityProof
} }
type identityRevealResponse struct { type identityRevealResponse struct {
@@ -75,6 +107,15 @@ type identityRevealResponse struct {
type identityDeleteRequest struct { type identityDeleteRequest struct {
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
UID string `json:"uid"` UID string `json:"uid"`
identityProof
}
// identityDeleteResponse reports what the delete actually removed. The purge
// counts are the point: "account removed" alone leaves the caller unable to
// tell a clean removal from one that silently stranded a decade of ratings.
type identityDeleteResponse struct {
UID string `json:"uid"`
Purged storage.UDLUserStats `json:"purged"`
} }
type identitySignRequest struct { type identitySignRequest struct {
@@ -88,6 +129,41 @@ type identitySignResponse struct {
SigB64 string `json:"sigB64"` SigB64 string `json:"sigB64"`
} }
// identitySignBatchRequest signs many payloads under one account in one call.
//
// Exists because a User Data Layer write signs exactly one record, and the bulk
// verbs above it ("mark this whole series seen") produce one record per episode:
// a 300-episode show is 300 sequential sign round trips through
// meta-watch -> meta-search -> here, which dominates the operation. The private
// key still never leaves this process; only the transport is batched.
type identitySignBatchRequest struct {
// PayloadsB64 are the canonical byte strings to sign, in order. The
// response's signatures line up index-for-index.
PayloadsB64 []string `json:"payloadsB64"`
// UID selects which account signs — same optional single-identity
// back-compat as identitySignRequest.
UID string `json:"uid,omitempty"`
}
type identitySignBatchResponse struct {
SigsB64 []string `json:"sigsB64"`
}
// maxSignBatch bounds one batch. A signature is cheap but not free, and the
// request holds the account lock for its duration; a caller with more than this
// should page. Sized well above the longest real series (Supernatural, 327).
const maxSignBatch = 1000
// reservedPayloadMessage explains the one refusal the signing endpoints make.
//
// This is the load-bearing half of proof-of-possession on a node that holds
// every private key: without it, a caller wanting to delete someone else's
// account just asks meta-core to sign the challenge for them, and the gate on
// reveal/delete becomes a formality. See identity.ChallengeDomain.
const reservedPayloadMessage = "payload is in the reserved account-authorisation domain (" +
identity.ChallengeDomain + "…); meta-core will not sign one. " +
"Signing a challenge is what proves you hold the key, so it must be done where the key is."
type identityAEADKeyResponse struct { type identityAEADKeyResponse struct {
AEADKeyB64 string `json:"aeadKeyB64"` AEADKeyB64 string `json:"aeadKeyB64"`
} }
@@ -217,23 +293,105 @@ func (s *Server) handleIdentityImport(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleIdentityReveal returns an existing account's private key. Deliberately // handleIdentityChallenge issues the one-shot text a client signs to prove it
// POST + explicit confirm (never a bare GET) so it cannot be triggered by a // holds an account's private key.
// link or an image tag, and so it reads as the deliberate act it is. The //
// endpoint is auth-gated at the perimeter like the rest of /api/identity/*. // Unauthenticated by design: a challenge is useless without the key, and
func (s *Server) handleIdentityReveal(w http.ResponseWriter, r *http.Request) { // GET /api/identity/accounts already publishes every uid, so refusing to mint
var body identityRevealRequest // one for an unknown caller would protect nothing. Requiring the account to
_ = json.NewDecoder(r.Body).Decode(&body) // exist is for a clear error, not secrecy.
if !body.Confirm && r.URL.Query().Get("confirm") != "true" { func (s *Server) handleIdentityChallenge(w http.ResponseWriter, r *http.Request) {
writeErrorSlug(w, http.StatusBadRequest, "confirm_required", var body identityChallengeRequest
"revealing a private key requires {\"confirm\": true} or ?confirm=true", false) if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return return
} }
uid := strings.TrimSpace(body.UID) uid := strings.TrimSpace(body.UID)
if uid == "" {
writeErrorSlug(w, http.StatusBadRequest, "uid_required", "uid is required", false)
return
}
action := identity.Action(strings.TrimSpace(body.Action))
if !identity.ValidAction(action) {
writeErrorSlug(w, http.StatusBadRequest, "bad_action",
`action must be "reveal" or "delete"`, false)
return
}
if !identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
writeErrorSlug(w, http.StatusNotFound, "no_identity", "no identity for the given uid", false)
return
}
text, expires, err := s.challenges.Issue(uid, action)
if err != nil {
if errors.Is(err, identity.ErrChallengeFull) {
writeErrorSlug(w, http.StatusServiceUnavailable, "challenges_full", err.Error(), true)
return
}
writeErrorSlug(w, http.StatusBadRequest, "bad_uid", err.Error(), false)
return
}
writeJSON(w, http.StatusOK, identityChallengeResponse{
Challenge: text,
ExpiresAt: expires.Unix(),
})
}
// requireProof resolves the target account and verifies the caller's
// proof-of-possession for `action`. status == 0 means the proof held.
//
// The uid is mandatory here, unlike elsewhere in this file: the old "sole
// account" fallback would have this pick a target the signature does not name,
// and a proof that authorises an unnamed account authorises the wrong one as
// soon as a second profile exists.
func (s *Server) requireProof(uid string, proof identityProof, action identity.Action) (
id *identity.Identity, status int, slug, msg string,
) {
uid = strings.TrimSpace(uid)
if uid == "" {
return nil, http.StatusBadRequest, "uid_required",
"uid is required: the signature authorises one specific account"
}
if proof.Challenge == "" || proof.Signature == "" {
return nil, http.StatusUnauthorized, "signature_required",
"this operation requires proof you hold the account's private key: " +
"POST /api/identity/challenge, sign the returned text, and resend it as {challenge, signature}"
}
sig, err := base64.StdEncoding.DecodeString(proof.Signature)
if err != nil {
return nil, http.StatusBadRequest, "bad_signature", "signature is not valid base64"
}
// Resolve before redeeming so a request naming a missing account does not
// burn a challenge it could not have used anyway.
id, status, slug, msg = s.resolveAccount(uid)
if status != 0 {
return nil, status, slug, msg
}
// Redeem first, verify second: the challenge is single-use either way, so a
// wrong signature costs the attacker a fresh round trip per guess rather
// than letting them grind one challenge offline against the same nonce.
if err := s.challenges.Redeem(proof.Challenge, uid, action); err != nil {
return nil, http.StatusUnauthorized, "bad_challenge", err.Error()
}
if err := identity.Verify(uid, []byte(proof.Challenge), sig); err != nil {
return nil, http.StatusUnauthorized, "bad_signature", err.Error()
}
return id, 0, "", ""
}
// handleIdentityReveal returns an existing account's private key to a caller
// that proves it already holds that key.
//
// Deliberately POST (never a bare GET) so it cannot be triggered by a link or
// an image tag. The proof is what actually guards it: before this gate existed,
// anything that could reach the port could read every private key on the node.
func (s *Server) handleIdentityReveal(w http.ResponseWriter, r *http.Request) {
var body identityRevealRequest
_ = json.NewDecoder(r.Body).Decode(&body)
uid := strings.TrimSpace(body.UID)
if uid == "" { if uid == "" {
uid = strings.TrimSpace(r.URL.Query().Get("uid")) uid = strings.TrimSpace(r.URL.Query().Get("uid"))
} }
id, status, slug, msg := s.resolveAccount(uid) id, status, slug, msg := s.requireProof(uid, body.identityProof, identity.ActionReveal)
if status != 0 { if status != 0 {
writeErrorSlug(w, status, slug, msg, false) writeErrorSlug(w, status, slug, msg, false)
return return
@@ -245,46 +403,62 @@ func (s *Server) handleIdentityReveal(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleIdentityDelete removes one account and everything it wrote.
//
// Two things happen, in this order and no other:
//
// 1. the account's User Data Layer rows are purged (cells plus every index
// that names the uid, including the cid-scoped ones shared with other
// accounts);
// 2. the keypair file is removed.
//
// Purge-then-delete so a failure never strands data: if the purge errors the
// key is still on disk, the account still exists, and the caller can retry.
// The reverse order would leave rows nobody can ever attribute, reach or
// rewrite — the orphaning this endpoint exists to prevent.
//
// What deletion still does NOT do, and cannot: records already signed under
// this uid and replicated to other peers keep verifying there. Removing a key
// is not a recall.
func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
var body identityDeleteRequest var body identityDeleteRequest
// Tolerate empty body — confirm flag in a query param is also acceptable // Tolerate an empty body so the uid may ride in the query — meta-watch's
// for shells where sending a JSON body with DELETE is awkward. // proxy puts it there. The proof itself must be in the body: a signature in
// a URL ends up in every access log between here and the browser.
_ = json.NewDecoder(r.Body).Decode(&body) _ = json.NewDecoder(r.Body).Decode(&body)
if !body.Confirm && r.URL.Query().Get("confirm") != "true" {
writeErrorSlug(w, http.StatusBadRequest, "confirm_required",
"identity deletion requires {\"confirm\": true} or ?confirm=true", false)
return
}
dir := s.config.IdentityAccountsDir()
uid := strings.TrimSpace(body.UID) uid := strings.TrimSpace(body.UID)
if uid == "" { if uid == "" {
uid = strings.TrimSpace(r.URL.Query().Get("uid")) uid = strings.TrimSpace(r.URL.Query().Get("uid"))
} }
if uid == "" { id, status, slug, msg := s.requireProof(uid, body.identityProof, identity.ActionDelete)
// Back-compat: with no uid, delete the sole account. Ambiguous (and so if status != 0 {
// refused) once more than one account exists. writeErrorSlug(w, status, slug, msg, false)
list, err := identity.List(dir) return
}
// A purge needs storage. Refusing here — rather than deleting the key and
// reporting the purge as "0 records" — is the difference between a retry
// and permanent orphaned data: once the key is gone the account cannot be
// named for a second attempt through this API.
if !s.storage.IsConnected() {
writeErrorSlug(w, http.StatusServiceUnavailable, "storage_unavailable",
"storage is not connected; refusing to delete the key while its data cannot be purged", true)
return
}
purged, err := s.storage.UDLPurgeUser(id.UID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeErrorSlug(w, http.StatusInternalServerError, "purge_failed",
"could not purge this account's data; the account was NOT deleted: "+err.Error(), true)
return return
} }
switch len(list) {
case 0: if err := identity.DeleteByUID(s.config.IdentityAccountsDir(), id.UID); err != nil {
w.WriteHeader(http.StatusNoContent) // idempotent writeErrorSlug(w, http.StatusInternalServerError, "delete_failed", err.Error(), false)
return
case 1:
uid = list[0].UID
default:
writeErrorSlug(w, http.StatusBadRequest, "uid_required",
"multiple accounts exist; specify uid to delete", false)
return return
} }
} log.Printf("[API] Deleted account %s and purged %d User Data Layer records across %d cids",
if err := identity.DeleteByUID(dir, uid); err != nil { id.UID, purged.Records, purged.Cids)
writeError(w, http.StatusInternalServerError, err.Error()) writeJSON(w, http.StatusOK, identityDeleteResponse{UID: id.UID, Purged: purged})
return
}
w.WriteHeader(http.StatusNoContent)
} }
func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
@@ -307,6 +481,10 @@ func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "bytesB64 is not valid base64") writeError(w, http.StatusBadRequest, "bytesB64 is not valid base64")
return return
} }
if identity.IsChallengePayload(raw) {
writeErrorSlug(w, http.StatusForbidden, "reserved_payload", reservedPayloadMessage, false)
return
}
sig, err := identity.Sign(id, raw) sig, err := identity.Sign(id, raw)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
@@ -317,6 +495,58 @@ func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleIdentitySignBatch signs every payload in the request under one account.
//
// All-or-nothing: a single bad base64 or signing failure fails the whole
// request rather than returning a short array. The caller pairs signatures with
// records by index, so a partial response would silently misalign them — and a
// misaligned signature verifies against the wrong record, which is worse than
// an error.
func (s *Server) handleIdentitySignBatch(w http.ResponseWriter, r *http.Request) {
var body identitySignBatchRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
uid := body.UID
if strings.TrimSpace(uid) == "" {
uid = r.URL.Query().Get("uid")
}
if len(body.PayloadsB64) == 0 {
writeError(w, http.StatusBadRequest, "payloadsB64 is required")
return
}
if len(body.PayloadsB64) > maxSignBatch {
writeError(w, http.StatusBadRequest, "too many payloads in one batch")
return
}
id, status, slug, msg := s.resolveAccount(uid)
if status != 0 {
writeErrorSlug(w, status, slug, msg, false)
return
}
sigs := make([]string, 0, len(body.PayloadsB64))
for i, p := range body.PayloadsB64 {
raw, err := base64.StdEncoding.DecodeString(p)
if err != nil {
writeError(w, http.StatusBadRequest, "payloadsB64["+strconv.Itoa(i)+"] is not valid base64")
return
}
if identity.IsChallengePayload(raw) {
writeErrorSlug(w, http.StatusForbidden, "reserved_payload",
"payloadsB64["+strconv.Itoa(i)+"]: "+reservedPayloadMessage, false)
return
}
sig, err := identity.Sign(id, raw)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
sigs = append(sigs, base64.StdEncoding.EncodeToString(sig))
}
writeJSON(w, http.StatusOK, identitySignBatchResponse{SigsB64: sigs})
}
func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) {
id, status, slug, msg := s.resolveAccount(r.URL.Query().Get("uid")) id, status, slug, msg := s.resolveAccount(r.URL.Query().Get("uid"))
if status != 0 { if status != 0 {
+250
View File
@@ -0,0 +1,250 @@
package api
// End-to-end exercise of the account gate against a live router + a temp store:
// generate -> write UDL rows -> stats -> challenge -> sign -> delete -> verify
// 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
// here (a challenge accepted for the wrong action, a purge that runs after the
// key is gone) live in the wiring.
import (
"bytes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/config"
"github.com/metazla/meta-core/internal/identity"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
stor := newTestStorage(t)
s := &Server{
config: &config.Config{MetaCorePath: t.TempDir()},
storage: stor,
challenges: identity.NewChallengeStore(),
router: mux.NewRouter(),
}
s.setupIdentityTestRoutes()
return s
}
// Only the routes under test, so this does not depend on every other subsystem
// booting.
func (s *Server) setupIdentityTestRoutes() {
s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET")
s.router.HandleFunc("/api/identity/challenge", s.handleIdentityChallenge).Methods("POST")
s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).Methods("POST")
s.router.HandleFunc("/api/identity/reveal", s.handleIdentityReveal).Methods("POST")
s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE")
s.router.HandleFunc("/api/identity/sign", s.handleIdentitySign).Methods("POST")
s.router.HandleFunc("/api/udl/users/stats", s.handleUDLUserStats).Methods("GET")
}
func do(t *testing.T, s *Server, method, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
var rdr *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
} else {
rdr = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, rdr)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
out := map[string]any{}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec, out
}
// signAs produces the proof a browser would, from the raw key.
func signAs(t *testing.T, privHex, challenge string) map[string]any {
t.Helper()
raw, err := hex.DecodeString(privHex)
if err != nil {
t.Fatalf("decode key: %v", err)
}
id, err := identity.Import(hex.EncodeToString(raw))
if err != nil {
t.Fatalf("import: %v", err)
}
sig, err := identity.Sign(id, []byte(challenge))
if err != nil {
t.Fatalf("sign: %v", err)
}
return map[string]any{
"challenge": challenge,
"signature": base64.StdEncoding.EncodeToString(sig),
}
}
func TestE2E_DeleteRequiresProofAndPurgesEverything(t *testing.T) {
s := newTestServer(t)
rec, gen := do(t, s, "POST", "/api/identity/generate", nil)
if rec.Code != http.StatusCreated {
t.Fatalf("generate: %d %s", rec.Code, rec.Body)
}
uid := gen["uid"].(string)
priv := gen["privateKeyHex"].(string)
// Some data to lose.
for _, cell := range [][2]string{{"cidA", "like"}, {"cidA", "rating"}, {"cidB", "seek"}} {
if ok, err := s.storage.UDLUpsertIfNewer(uid, cell[0], cell[1], 1, 100, "rec", "", false, false); err != nil || !ok {
t.Fatalf("seed: %v", err)
}
}
// The count the UI shows in the confirmation.
rec, stats := do(t, s, "GET", "/api/udl/users/stats", nil)
if rec.Code != http.StatusOK {
t.Fatalf("stats: %d %s", rec.Code, rec.Body)
}
users := stats["users"].(map[string]any)
mine := users[uid].(map[string]any)
if mine["records"].(float64) != 3 || mine["cids"].(float64) != 2 {
t.Fatalf("stats = %v, want 3 records / 2 cids", mine)
}
// Unsigned delete must be refused, and must not have touched anything.
rec, body := do(t, s, "DELETE", "/api/identity", map[string]any{"uid": uid, "confirm": true})
if rec.Code != http.StatusUnauthorized || body["error"] != "signature_required" {
t.Fatalf("unsigned delete = %d %v, want 401 signature_required", rec.Code, body)
}
if !identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
t.Fatal("a refused delete removed the key anyway")
}
// meta-core must refuse to sign the caller's authorisation for them —
// without this the gate is decoration on a node holding every key.
ch, _, err := s.challenges.Issue(uid, identity.ActionDelete)
if err != nil {
t.Fatalf("issue: %v", err)
}
rec, body = do(t, s, "POST", "/api/identity/sign", map[string]any{
"uid": uid,
"bytesB64": base64.StdEncoding.EncodeToString([]byte(ch)),
})
if rec.Code != http.StatusForbidden || body["error"] != "reserved_payload" {
t.Fatalf("sign of a challenge = %d %v, want 403 reserved_payload", rec.Code, body)
}
// A challenge minted for reveal must not authorise a delete.
rec, chBody := do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "reveal"})
if rec.Code != http.StatusOK {
t.Fatalf("challenge: %d %s", rec.Code, rec.Body)
}
proof := signAs(t, priv, chBody["challenge"].(string))
req := map[string]any{"uid": uid, "confirm": true}
for k, v := range proof {
req[k] = v
}
rec, body = do(t, s, "DELETE", "/api/identity", req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("reveal challenge used for delete = %d %v, want 401", rec.Code, body)
}
// The real thing.
rec, chBody = do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "delete"})
if rec.Code != http.StatusOK {
t.Fatalf("challenge: %d %s", rec.Code, rec.Body)
}
proof = signAs(t, priv, chBody["challenge"].(string))
req = map[string]any{"uid": uid, "confirm": true}
for k, v := range proof {
req[k] = v
}
rec, body = do(t, s, "DELETE", "/api/identity", req)
if rec.Code != http.StatusOK {
t.Fatalf("signed delete = %d %s", rec.Code, rec.Body)
}
purged := body["purged"].(map[string]any)
if purged["records"].(float64) != 3 || purged["cids"].(float64) != 2 {
t.Fatalf("purged = %v, want 3 records / 2 cids", purged)
}
// Key gone.
if identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
t.Fatal("the account file survived the delete")
}
// Data gone — the orphaning this whole change exists to prevent.
after, err := s.storage.UDLAllUserStats()
if err != nil {
t.Fatalf("stats after: %v", err)
}
if got, present := after[uid]; present {
t.Fatalf("deleted account still holds %+v", got)
}
for _, cid := range []string{"cidA", "cidB"} {
uids, err := s.storage.UDLCidUsers(cid)
if err != nil {
t.Fatalf("cid users: %v", err)
}
if len(uids) != 0 {
t.Fatalf("%s still names %v after the purge", cid, uids)
}
}
// The challenge is single-use, so replaying the same proof does nothing.
rec, _ = do(t, s, "DELETE", "/api/identity", req)
if rec.Code == http.StatusOK {
t.Fatal("a replayed proof must not succeed")
}
}
func TestE2E_RevealRequiresProof(t *testing.T) {
s := newTestServer(t)
_, gen := do(t, s, "POST", "/api/identity/generate", nil)
uid := gen["uid"].(string)
priv := gen["privateKeyHex"].(string)
rec, body := do(t, s, "POST", "/api/identity/reveal", map[string]any{"uid": uid, "confirm": true})
if rec.Code != http.StatusUnauthorized || body["error"] != "signature_required" {
t.Fatalf("unsigned reveal = %d %v, want 401 signature_required", rec.Code, body)
}
_, chBody := do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "reveal"})
proof := signAs(t, priv, chBody["challenge"].(string))
req := map[string]any{"uid": uid, "confirm": true}
for k, v := range proof {
req[k] = v
}
rec, body = do(t, s, "POST", "/api/identity/reveal", req)
if rec.Code != http.StatusOK {
t.Fatalf("signed reveal = %d %s", rec.Code, rec.Body)
}
if body["privateKeyHex"] != priv {
t.Fatal("reveal returned a different key than generate did")
}
}
// Another account's key must not authorise this one, even with a challenge
// minted for the right uid and action.
func TestE2E_AnotherAccountsKeyIsRefused(t *testing.T) {
s := newTestServer(t)
_, alice := do(t, s, "POST", "/api/identity/generate", nil)
_, mallory := do(t, s, "POST", "/api/identity/generate", nil)
uid := alice["uid"].(string)
_, chBody := do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "delete"})
proof := signAs(t, mallory["privateKeyHex"].(string), chBody["challenge"].(string))
req := map[string]any{"uid": uid, "confirm": true}
for k, v := range proof {
req[k] = v
}
rec, body := do(t, s, "DELETE", "/api/identity", req)
if rec.Code != http.StatusUnauthorized || body["error"] != "bad_signature" {
t.Fatalf("mallory deleting alice = %d %v, want 401 bad_signature", rec.Code, body)
}
if !identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
t.Fatal("alice's account was deleted by mallory's signature")
}
}
+40 -59
View File
@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@@ -39,6 +38,36 @@ 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"`
// Filters is the structured form: AND across entries, OR within an
// entry's Values. Narrowed in SQL through the field index alongside the
// free-text Query, then verified per record (see serveIndexedSearch), so an
// id-anchored lookup costs an index probe rather than a read per record.
//
// Property/PropertyValue is the single-filter legacy shape and folds into
// this list (see normalizedFilters) with Exact=false, preserving its
// substring semantics byte-for-byte.
Filters []MetadataFilter `json:"filters,omitempty"`
}
// MetadataFilter is one structured constraint on a record.
//
// A record satisfies it when ANY of Values matches, and a search result must
// satisfy EVERY filter (OR within, AND across). An entry with no usable value
// constrains nothing — same rule the gateway's `genres_filter_matches` uses, so
// a caller that lowers an empty filter doesn't accidentally empty the result.
type MetadataFilter struct {
// Property is the field name, WITHOUT a key-set suffix: `genres`, not
// `genres/Action`. Both storage shapes are probed — see recordMatchesFilter.
Property string `json:"property"`
// Values are OR'd. Compared case-insensitively.
Values []string `json:"values,omitempty"`
// Exact selects CSV-exact comparison (the semantics meta-gateway's
// `any_csv_value_matches` applies at its own Layer-B re-filter) over
// substring containment. Callers doing identity lookups want this: with a
// result limit, a substring match on `tmdbid=1405` also admits `21405` and
// can crowd the real matches out of the page.
Exact bool `json:"exact,omitempty"`
} }
// MetadataSearchResult is a single search result // MetadataSearchResult is a single search result
@@ -415,65 +444,17 @@ func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
return return
} }
// Free-text query: served from the in-memory search index (search_index.go) // Everything else — free text, structured filters, both, or neither
// so we match in memory instead of doing a SCAN+MGET Redis round-trip per // (which returns the first `limit`) — is served from the store's derived
// record (that was 40s+ over ~72k records). Full metadata is fetched only // search indexes (search_index.go → storage.Search).
// for the <=limit matches. //
if req.Query != "" && req.Property == "" { // There is deliberately no second path. The property branch used to fall
// through to a `GetAllHashIDs` + per-record `GetMetadataFlat` scan, which
// is what made an id-anchored lookup 3.63s over ~20k records (vs 10ms for
// the free-text branch on the same corpus) and kept meta-core out of the
// gateway's anchored search path entirely. The index already holds every
// field of every record, so that scan was re-reading data it had in hand.
s.serveIndexedSearch(w, req) s.serveIndexedSearch(w, req)
return
}
// Property filter (rare) or empty query (returns the first `limit`): the
// legacy scan. Empty query only fetches `limit` records; property search is
// low-volume and not on the hot search path.
allHashIDs, err := s.storage.GetAllHashIDs()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var results []MetadataSearchResult
total := len(allHashIDs)
for _, hashID := range allHashIDs {
if len(results) >= req.Limit {
break
}
metadata, err := s.storage.GetMetadataFlat(hashID)
if err != nil || metadata == nil {
continue
}
matches := false
// Property search
if req.Property != "" && req.PropertyValue != "" {
value, ok := metadata[req.Property]
if ok && strings.Contains(strings.ToLower(value), strings.ToLower(req.PropertyValue)) {
matches = true
}
}
// If no query or property filter, return all
if req.Query == "" && req.Property == "" {
matches = true
}
if matches {
results = append(results, MetadataSearchResult{
HashID: hashID,
Metadata: metadata,
})
}
}
writeJSON(w, http.StatusOK, MetadataSearchResponse{
Results: results,
Count: len(results),
Total: total,
})
} }
// handleBatchUpdate handles POST /api/metadata/batch // handleBatchUpdate handles POST /api/metadata/batch
+17 -14
View File
@@ -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,
}) })
} }
+211
View File
@@ -0,0 +1,211 @@
package api
import "testing"
// Tests for the structured-filter matcher that lets meta-core answer an
// id-anchored query from the in-memory search index (search_index.go).
//
// Why this matters beyond "filters work": meta-core is a *coarse pre-filter*
// for meta-gateway, which re-evaluates every record it emits through its own
// `query_eval::record_matches`. So the property that has to hold is that this
// matcher stays a SUPERSET of that one — it may admit records the gateway will
// drop, but it must never drop a record the gateway would have kept. Every
// case below is written from that angle:
//
// - key-set fields (`genres/Action`) must match a `genres:action` filter, or
// card-tier records vanish from a genre-scoped query;
// - a missing field fails CLOSED, matching record_matches;
// - an empty/unusable filter constrains NOTHING, so a caller that lowers a
// blank filter gets its results rather than an empty page.
func f(property string, exact bool, values ...string) MetadataFilter {
return MetadataFilter{Property: property, Values: values, Exact: exact}
}
func TestRecordMatchesFilter(t *testing.T) {
fields := map[string]string{
"fileType": "video",
"videoType": "tv,anime",
"tmdbid": "1405",
"genres/Action": "true",
"genres/Science Fiction": "true",
"source/gateway:nyaa.si": "true",
"categories/XXX": "true",
"retracted": "false",
}
cases := []struct {
name string
filter MetadataFilter
want bool
}{
// Flat scalar.
{"flat exact hit", f("fileType", true, "video"), true},
{"flat exact miss", f("fileType", true, "audio"), false},
{"flat exact is case-insensitive", f("fileType", true, "VIDEO"), true},
// csv-set field: each comma piece is compared independently.
{"csv member hit", f("videoType", true, "anime"), true},
{"csv member miss", f("videoType", true, "movie"), false},
// Exact vs contains — the reason Exact exists. A substring match on an
// identity field admits neighbours and, under a result limit, crowds
// the real matches off the page.
{"contains admits a superstring", f("tmdbid", false, "140"), true},
{"exact rejects a superstring", f("tmdbid", true, "140"), false},
{"exact accepts the whole value", f("tmdbid", true, "1405"), true},
// Key-set shape. The stored member is verbatim (`genres/Action`) while
// query values arrive lowercased, so the case-insensitive sweep is what
// makes a genre-scoped row work at all.
{"key-set exact casing", f("genres", true, "Action"), true},
{"key-set lowercased query", f("genres", true, "action"), true},
{"key-set with a space", f("genres", true, "science fiction"), true},
{"key-set miss", f("genres", true, "horror"), false},
{"key-set label containing a colon", f("source", true, "gateway:nyaa.si"), true},
// OR within one filter's values.
{"or hits on the second value", f("fileType", true, "audio", "video"), true},
{"or misses on all values", f("fileType", true, "audio", "image"), false},
// Fail-closed on an absent field — mirrors record_matches, and is what
// keeps a filtered query from returning unrelated records.
{"absent field fails closed", f("imdbid", true, "tt0903747"), false},
// A filter that constrains nothing must not empty the result.
{"no values constrains nothing", f("fileType", true), true},
{"blank value constrains nothing", f("fileType", true, " "), true},
{"no property constrains nothing", f("", true, "video"), true},
// A FLAT field whose value happens to be "false" is still a value, and
// an exact filter for it matches. Only the key-set arm treats "false"
// as non-membership (pinned in the test below).
{"flat field valued false still matches", f("retracted", true, "false"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := recordMatchesFilter(fields, &tc.filter); got != tc.want {
t.Fatalf("recordMatchesFilter(%+v) = %v, want %v", tc.filter, got, tc.want)
}
})
}
}
// `retracted = "false"` is a flat field, not a key-set member, so the flat arm
// legitimately matches the literal string "false". The key-set arm is what must
// treat it as non-membership — pinned separately so the two arms don't get
// conflated if either is refactored.
func TestFalseyKeySetMemberIsNotMembership(t *testing.T) {
fields := map[string]string{"genres/Action": "false", "genres/Drama": "0"}
for _, v := range []string{"Action", "Drama"} {
filter := f("genres", true, v)
if recordMatchesFilter(fields, &filter) {
t.Fatalf("genres/%s with a falsey value must not assert membership", v)
}
}
}
func TestRecordMatchesFiltersIsAndAcrossEntries(t *testing.T) {
fields := map[string]string{"fileType": "video", "season": "2"}
all := []MetadataFilter{f("fileType", true, "video"), f("season", true, "2")}
if !recordMatchesFilters(fields, all) {
t.Fatal("a record satisfying every filter must match")
}
one := []MetadataFilter{f("fileType", true, "video"), f("season", true, "3")}
if recordMatchesFilters(fields, one) {
t.Fatal("filters are AND across entries — one miss must reject the record")
}
if !recordMatchesFilters(fields, nil) {
t.Fatal("no filters must match everything")
}
}
// The legacy Property/PropertyValue pair keeps its substring semantics: it is
// the shape existing callers already use, and tightening it silently would
// change results for them. New callers pass Filters and opt into Exact.
func TestNormalizedFiltersFoldsTheLegacyPair(t *testing.T) {
got := normalizedFilters(MetadataSearchRequest{Property: "tmdbid", PropertyValue: "1405"})
if len(got) != 1 {
t.Fatalf("legacy pair must fold into exactly one filter, got %d", len(got))
}
if got[0].Property != "tmdbid" || len(got[0].Values) != 1 || got[0].Values[0] != "1405" {
t.Fatalf("legacy pair folded incorrectly: %+v", got[0])
}
if got[0].Exact {
t.Fatal("the legacy pair must stay substring (Exact=false) for compatibility")
}
// A half-specified pair is not a filter — it must not become one that
// matches nothing and empties the response.
if n := len(normalizedFilters(MetadataSearchRequest{Property: "tmdbid"})); n != 0 {
t.Fatalf("a property with no value must not fold into a filter, got %d", n)
}
if n := len(normalizedFilters(MetadataSearchRequest{PropertyValue: "1405"})); n != 0 {
t.Fatalf("a value with no property must not fold into a filter, got %d", n)
}
both := normalizedFilters(MetadataSearchRequest{
Property: "tmdbid",
PropertyValue: "1405",
Filters: []MetadataFilter{f("fileType", true, "video")},
})
if len(both) != 2 {
t.Fatalf("the legacy pair must be appended to explicit filters, got %d", len(both))
}
}
// The acceptance bar for routing the anchored path through the index: a
// full-miss filtered sweep over a realistic corpus must stay in the same class
// as the free-text branch (measured 10 ms over 19 622 live records), not the
// legacy per-record Redis scan it replaces (measured 3.63 s over the same
// corpus). Full-miss is the worst case — the loop cannot short-circuit on the
// result limit, so every record is evaluated.
//
// Run: go test ./internal/api/ -bench BenchmarkFilterSweep -benchtime 10x
func BenchmarkFilterSweep(b *testing.B) {
const records = 20000
corpus := make([]map[string]string, records)
for i := range corpus {
corpus[i] = map[string]string{
"title": "Some Release",
"fileType": "video",
"videoType": "tv",
"tmdbid": "1405",
"season": "2",
"genres/Action": "true",
"genres/Drama": "true",
"source/gateway:nyaa.si": "true",
"categories/TV/Anime": "true",
"indexer": "nyaa.si",
"fileName": "some.release.s02e05.mkv",
"description/eng": "text",
"poster": "bafy",
"sizeByte": "123456789",
"contentKind": "episode",
"anchoredBy/tmdb:1405": "true",
}
}
// A miss on the identity field: the shape an id-anchored gateway query
// takes, and the one that must not degrade.
filters := []MetadataFilter{
f("tmdbid", true, "999999"),
f("fileType", true, "video"),
f("genres", true, "action"),
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
hits := 0
for _, fields := range corpus {
if recordMatchesFilters(fields, filters) {
hits++
}
}
if hits != 0 {
b.Fatalf("expected a full miss, got %d hits", hits)
}
}
}
+149 -79
View File
@@ -1,100 +1,59 @@
package api package api
import ( import (
"log"
"net/http" "net/http"
"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.
// searchIndex is an in-memory snapshot of every record's search haystack //
// (hashID + lowercased searchable fields), rebuilt periodically from Redis. It // Until 2026-10 this ran against an in-memory copy of every record, rebuilt
// exists so POST /api/metadata/search matches in memory (string ops over a // from Redis; that copy cost ~30 KB of RAM per record (5.1 GB on watch.nsl.sh)
// slice) instead of doing a SCAN+MGET Redis round-trip per record — the latter // and is gone. The matchers did not change, so neither did the semantics.
// took 40s+ over ~72k records and made a live parallel search path unusable. //
type searchIndex struct { // This is the ONLY search path. Free text, filters, both, or neither all land
mu sync.RWMutex // here; only the `hashId` exact-match branch in handleSearchMetadata bypasses it.
tuples []storage.SearchTuple
ready atomic.Bool
}
// snapshot returns the current tuples slice and whether the index has built at
// least once. The refresh 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()
}
// refresh rebuilds the index from Redis once (a batched bulk read).
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
}
idx.mu.Lock()
idx.tuples = tuples
idx.mu.Unlock()
idx.ready.Store(true)
}
// runRefreshLoop builds the index immediately, then rebuilds on a ticker for the
// process lifetime.
func (idx *searchIndex) runRefreshLoop(stor *storage.Client) {
idx.refresh(stor)
ticker := time.NewTicker(searchIndexRefreshInterval)
defer ticker.Stop()
for range ticker.C {
idx.refresh(stor)
}
}
// serveIndexedSearch answers a free-text (non-property) query from the in-memory
// index: token-AND match against each record's haystack (or a whole-query hashID
// substring, preserving the legacy behaviour), then fetch full metadata for only
// the <=limit matches. No Redis round-trip per scanned record.
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)
results := make([]MetadataSearchResult, 0, req.Limit) filters := normalizedFilters(req)
for _, t := range tuples {
if len(results) >= req.Limit { narrow := make([]storage.SearchFilter, 0, len(filters))
break for _, f := range filters {
narrow = append(narrow, storage.SearchFilter{Property: f.Property, Values: f.Values})
} }
if !matchAllTokens(t.Haystack, tokens) && hits, err := s.storage.Search(req.Query, narrow, req.Limit, func(hashID string, fields map[string]string) bool {
!strings.Contains(strings.ToLower(t.HashID), queryLower) { if req.Query != "" &&
continue !matchAllTokens(storage.Haystack(fields), tokens) &&
!strings.Contains(strings.ToLower(hashID), queryLower) {
return false
} }
// Full metadata is cached in the index — return it straight from memory, return recordMatchesFilters(fields, filters)
// no Redis round-trip per result. })
results = append(results, MetadataSearchResult{HashID: t.HashID, Metadata: t.Fields}) if err != nil {
writeError(w, http.StatusInternalServerError, "search failed: "+err.Error())
return
}
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),
}) })
} }
@@ -108,3 +67,114 @@ func matchAllTokens(haystack string, tokens []string) bool {
} }
return true return true
} }
// normalizedFilters returns the request's structured filters with the legacy
// Property/PropertyValue pair folded in as one more entry.
//
// The legacy pair keeps Exact=false so its long-standing substring semantics
// are unchanged for existing callers; new callers pass Filters and choose.
func normalizedFilters(req MetadataSearchRequest) []MetadataFilter {
if req.Property == "" || req.PropertyValue == "" {
return req.Filters
}
out := make([]MetadataFilter, 0, len(req.Filters)+1)
out = append(out, req.Filters...)
return append(out, MetadataFilter{
Property: req.Property,
Values: []string{req.PropertyValue},
})
}
// recordMatchesFilters reports whether fields satisfies every filter (AND).
func recordMatchesFilters(fields map[string]string, filters []MetadataFilter) bool {
for i := range filters {
if !recordMatchesFilter(fields, &filters[i]) {
return false
}
}
return true
}
// recordMatchesFilter reports whether fields satisfies one filter (OR within
// its values), probing BOTH storage shapes a field can take:
//
// - a flat scalar/csv-set field — `fileType = "video"`, `videoType = "tv,anime"`;
// - a key-set member — `genres/Action = "true"`, `source/gateway:nyaa.si = "true"`
// (METADATA_KEYS "How the store is shaped").
//
// Probing both is what lets one filter shape serve the whole vocabulary. The
// gateway needs a `genres:action` filter to find a record whose genre lives in
// the key `genres/Action`, and a `fileType:video` filter to find one whose value
// lives in the field `fileType` — without meta-core knowing which fields are
// key-sets. It also means this stays a SUPERSET of the gateway's own
// `record_matches`, which is the contract: meta-core pre-filters, the gateway
// re-filters authoritatively.
//
// A filter with no usable value constrains nothing (matches). A filter whose
// field is absent fails closed, matching `record_matches`.
func recordMatchesFilter(fields map[string]string, f *MetadataFilter) bool {
values := make([]string, 0, len(f.Values))
for _, v := range f.Values {
if v = strings.TrimSpace(v); v != "" {
values = append(values, v)
}
}
if f.Property == "" || len(values) == 0 {
return true
}
// Key-set shape: `<property>/<value>` present and truthy. Try the exact
// key first — that hits whenever the caller's casing matches what was
// written, which is the common case and costs one map probe.
for _, v := range values {
if isTruthyMember(fields[f.Property+"/"+v]) {
return true
}
}
// Members are written verbatim (`genres/Action`) while query values arrive
// lowercased, so fall back to one case-insensitive sweep. Single pass over
// the record's fields for ALL values, not one pass per value.
wanted := make(map[string]struct{}, len(values))
for _, v := range values {
wanted[strings.ToLower(v)] = struct{}{}
}
prefix := strings.ToLower(f.Property) + "/"
for k, val := range fields {
if len(k) <= len(prefix) || !strings.EqualFold(k[:len(prefix)], prefix) {
continue
}
if _, ok := wanted[strings.ToLower(k[len(prefix):])]; ok && isTruthyMember(val) {
return true
}
}
// Flat shape: the field's own value, split on `,` for csv-set fields.
fieldValue, ok := fields[f.Property]
if !ok {
return false
}
for _, piece := range strings.Split(fieldValue, ",") {
piece = strings.TrimSpace(piece)
if piece == "" {
continue
}
for _, v := range values {
if f.Exact {
if strings.EqualFold(piece, v) {
return true
}
} else if strings.Contains(strings.ToLower(piece), strings.ToLower(v)) {
return true
}
}
}
return false
}
// isTruthyMember reports whether a key-set member's value asserts membership.
// Mirrors the gateway's `is_truthy_member`: presence with a non-empty,
// non-"false"/"0" value.
func isTruthyMember(v string) bool {
v = strings.TrimSpace(strings.ToLower(v))
return v != "" && v != "false" && v != "0"
}
+73 -103
View File
@@ -9,10 +9,9 @@ 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/discovery"
"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/mounts" "github.com/metazla/meta-core/internal/mounts"
"github.com/metazla/meta-core/internal/schema" "github.com/metazla/meta-core/internal/schema"
"github.com/metazla/meta-core/internal/storage" "github.com/metazla/meta-core/internal/storage"
@@ -25,8 +24,6 @@ import (
type Server struct { type Server struct {
config *config.Config config *config.Config
leaderProvider *leader.LeaderInfoProvider leaderProvider *leader.LeaderInfoProvider
discovery *discovery.Service
cleaner *discovery.Cleaner
storage *storage.Client storage *storage.Client
mountsManager *mounts.Manager mountsManager *mounts.Manager
mountsHandlers *mounts.Handlers mountsHandlers *mounts.Handlers
@@ -38,37 +35,48 @@ 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
// restart invalidates the ones in flight — see identity/challenge.go.
challenges *identity.ChallengeStore
// mesh is the UDP discovery node (beacon v2). nil when discovery
// is disabled or failed to bind, in which case /api/neighbors reports an
// empty list rather than erroring — a dashboard nav is decoration and
// must not take the API down with it.
mesh *meshdisco.Node
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
// dashboard never puts a multi-second store walk on the request path.
stats *statsCache
}
// SetMeshNode attaches the UDP discovery node. Separate from NewServer so the
// server's constructor signature (and its tests) stay untouched.
func (s *Server) SetMeshNode(n *meshdisco.Node) {
s.mesh = n
} }
// NewServer creates a new API server // NewServer creates a new API server
func NewServer( func NewServer(
cfg *config.Config, cfg *config.Config,
leaderProvider *leader.LeaderInfoProvider, leaderProvider *leader.LeaderInfoProvider,
disc *discovery.Service,
cleaner *discovery.Cleaner,
stor *storage.Client, stor *storage.Client,
) *Server { ) *Server {
s := &Server{ s := &Server{
config: cfg, config: cfg,
leaderProvider: leaderProvider, leaderProvider: leaderProvider,
discovery: disc,
cleaner: cleaner,
storage: stor, storage: stor,
search: &searchIndex{},
router: mux.NewRouter(), router: mux.NewRouter(),
stats: &statsCache{},
} }
// Initialize file watcher/scanner and dispatcher // Initialize file watcher/scanner and dispatcher
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)
} }
@@ -78,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)
} }
@@ -97,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()
@@ -111,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)
} }
} }
} }
@@ -139,6 +146,8 @@ func NewServer(
// Initialize WebDAV handler (caching is handled by nginx proxy_cache) // Initialize WebDAV handler (caching is handled by nginx proxy_cache)
s.webdavHandler = webdav.NewHandler(cfg) s.webdavHandler = webdav.NewHandler(cfg)
s.challenges = identity.NewChallengeStore()
// One-time migration: fold a pre-multi-account identity.json into the // One-time migration: fold a pre-multi-account identity.json into the
// per-account keystore so existing single-identity hosts keep their uid. // per-account keystore so existing single-identity hosts keep their uid.
if migrated, err := identity.MigrateLegacy(cfg.IdentityFilePath(), cfg.IdentityAccountsDir()); err != nil { if migrated, err := identity.MigrateLegacy(cfg.IdentityFilePath(), cfg.IdentityAccountsDir()); err != nil {
@@ -159,6 +168,10 @@ func (s *Server) setupRoutes() {
s.router.HandleFunc("/leader", s.handleLeader).Methods("GET") s.router.HandleFunc("/leader", s.handleLeader).Methods("GET")
s.router.HandleFunc("/urls", s.handleURLs).Methods("GET") s.router.HandleFunc("/urls", s.handleURLs).Methods("GET")
// Dashboard headline counters in one request. Cheap half live, keyspace
// walks served from a background-refreshed cache — see stats.go.
s.router.HandleFunc("/api/stats", s.handleStats).Methods("GET")
// Metadata Editor API routes (must be before /meta/{hash} routes) // Metadata Editor API routes (must be before /meta/{hash} routes)
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")
@@ -170,11 +183,13 @@ func (s *Server) setupRoutes() {
// at the perimeter — do NOT add to nginx-hash-lock unauth bypass. // at the perimeter — do NOT add to nginx-hash-lock unauth bypass.
s.router.HandleFunc("/api/identity", s.handleIdentityGet).Methods("GET") s.router.HandleFunc("/api/identity", s.handleIdentityGet).Methods("GET")
s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET") s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET")
s.router.HandleFunc("/api/identity/challenge", s.handleIdentityChallenge).Methods("POST")
s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).Methods("POST") s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).Methods("POST")
s.router.HandleFunc("/api/identity/import", s.handleIdentityImport).Methods("POST") s.router.HandleFunc("/api/identity/import", s.handleIdentityImport).Methods("POST")
s.router.HandleFunc("/api/identity/reveal", s.handleIdentityReveal).Methods("POST") s.router.HandleFunc("/api/identity/reveal", s.handleIdentityReveal).Methods("POST")
s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE") s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE")
s.router.HandleFunc("/api/identity/sign", s.handleIdentitySign).Methods("POST") s.router.HandleFunc("/api/identity/sign", s.handleIdentitySign).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) // Snapshot (export / import / wipe)
@@ -206,10 +221,14 @@ func (s *Server) setupRoutes() {
// Continue Watching, ratings, profile). See internal/api/udl.go. // Continue Watching, ratings, profile). See internal/api/udl.go.
s.router.HandleFunc("/api/udl/record", s.handleUDLRecordGet).Methods("GET") s.router.HandleFunc("/api/udl/record", s.handleUDLRecordGet).Methods("GET")
s.router.HandleFunc("/api/udl/record", s.handleUDLRecordPut).Methods("PUT") s.router.HandleFunc("/api/udl/record", s.handleUDLRecordPut).Methods("PUT")
s.router.HandleFunc("/api/udl/records", s.handleUDLRecordsPut).Methods("PUT")
s.router.HandleFunc("/api/udl/user/{uid}/key/{key}", s.handleUDLUserKey).Methods("GET") s.router.HandleFunc("/api/udl/user/{uid}/key/{key}", s.handleUDLUserKey).Methods("GET")
s.router.HandleFunc("/api/udl/user/{uid}/cid/{cid}", s.handleUDLUserCid).Methods("GET") s.router.HandleFunc("/api/udl/user/{uid}/cid/{cid}", s.handleUDLUserCid).Methods("GET")
s.router.HandleFunc("/api/udl/user/{uid}/all", s.handleUDLUserAll).Methods("GET")
s.router.HandleFunc("/api/udl/user/{uid}/count", s.handleUDLUserCount).Methods("GET")
s.router.HandleFunc("/api/udl/cid/{cid}/users", s.handleUDLCidUsers).Methods("GET") s.router.HandleFunc("/api/udl/cid/{cid}/users", s.handleUDLCidUsers).Methods("GET")
s.router.HandleFunc("/api/udl/cid/{cid}/aggregate", s.handleUDLAggregate).Methods("GET") s.router.HandleFunc("/api/udl/cid/{cid}/aggregate", s.handleUDLAggregate).Methods("GET")
s.router.HandleFunc("/api/udl/users/stats", s.handleUDLUserStats).Methods("GET")
// 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")
@@ -234,30 +253,31 @@ func (s *Server) setupRoutes() {
s.router.HandleFunc("/api/file/{cid}/info", s.handleGetFileByCIDInfo).Methods("GET") s.router.HandleFunc("/api/file/{cid}/info", s.handleGetFileByCIDInfo).Methods("GET")
s.router.HandleFunc("/file/{cid}", s.handleGetFileByCID).Methods("GET") s.router.HandleFunc("/file/{cid}", s.handleGetFileByCID).Methods("GET")
s.router.HandleFunc("/file/cid", s.handleComputeFileCID).Methods("POST") s.router.HandleFunc("/file/cid", s.handleComputeFileCID).Methods("POST")
// Register one file under /files/plugin by midhash — the watcher's
// mint + alias + file:events path for a single file. Registered
// unconditionally: it exists for boxes whose watcher is OFF.
s.router.HandleFunc("/api/files/register", s.handleRegisterFile).Methods("POST")
// File-backed roots (filePath+sizeByte+mtimeNano) with sizes, batched
// server-side, so clients answer "how many files, how big" without one
// read per hashId. ?summary=1 returns only {count, totalSize}.
s.router.HandleFunc("/api/files/tuples", s.handleGetFileTuples).Methods("GET")
// Public metadata-by-CID resolver. Reverse-index lookup → full document. // Public metadata-by-CID resolver. Reverse-index lookup → full document.
// Auth-bypassed alongside /api/file/{cid} so meta-share peers can query // Auth-bypassed alongside /api/file/{cid} so meta-share peers can query
// 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")
// 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")
s.router.HandleFunc("/api/events/meta", s.handleEventsMeta).Methods("GET") s.router.HandleFunc("/api/events/meta", s.handleEventsMeta).Methods("GET")
// Service discovery // beacon v2: neighbours heard over UDP. Supersedes /api/services,
// Both /services and /api/services are supported for consistency with other services // which is still served above while consumers migrate.
s.router.HandleFunc("/services", s.handleListServices).Methods("GET") s.router.HandleFunc("/neighbors", s.handleListNeighbors).Methods("GET")
s.router.HandleFunc("/api/services", s.handleListServices).Methods("GET") s.router.HandleFunc("/api/neighbors", s.handleListNeighbors).Methods("GET")
s.router.HandleFunc("/services/cleanup/stats", s.handleCleanupStats).Methods("GET")
s.router.HandleFunc("/services/{name}", s.handleGetService).Methods("GET")
// Mount management routes (if manager initialized) // Mount management routes (if manager initialized)
if s.mountsHandlers != nil { if s.mountsHandlers != nil {
@@ -326,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 {
log.Printf("[API] Warning: failed to start meta publisher: %v", err)
} else {
// Schema indexer consumes the same stream to derive a live
// per-field schema (type/hint/breakdown). Start it before the
// republish flood so it captures the bootstrap events.
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 func() { 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() started := time.Now()
if n, err := s.storage.WarmFieldIndexes(); err != nil { if n, err := s.storage.RebuildSearchIfEmpty(); err != nil {
log.Printf("[API] Warning: field index warm failed: %v (reads fall back to SCAN)", err) log.Printf("[API] Warning: search row rebuild failed: %v", err)
} else if n > 0 { } else if n > 0 {
log.Printf("[API] Warmed %d field indexes in %s", n, time.Since(started).Round(time.Millisecond)) log.Printf("[API] Rebuilt search rows for %d records 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)
} }
}() }()
} go s.logWriteStats()
} }
return nil return nil
@@ -400,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
} }
@@ -426,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()
for range ticker.C {
store := s.storage.Store()
if store == nil {
return
} }
if s.storage == nil || !s.storage.IsConnected() { changed, unchanged := store.WriteStats()
return 0, fmt.Errorf("storage not connected") if total := changed + unchanged; total > 0 {
log.Printf("[Storage] writes since start: %d changed, %d unchanged skipped (%.0f%% no-op)", changed, unchanged, 100*float64(unchanged)/float64(total))
} }
// Create the republish function that wraps storage methods
republishFunc := func() ([]string, func(string) (map[string]string, error), error) {
hashIDs, err := s.storage.GetAllHashIDs()
if err != nil {
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 -1
View File
@@ -103,7 +103,7 @@ func (s *Server) handleSnapshotImport(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }
// handleSnapshotWipe clears Redis metadata. // handleSnapshotWipe clears all stored metadata.
// POST /api/snapshot/wipe?scope=metadata // POST /api/snapshot/wipe?scope=metadata
func (s *Server) handleSnapshotWipe(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSnapshotWipe(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() { if !s.storage.IsConnected() {
+134 -85
View File
@@ -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,29 @@ 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 {
cursor = resolveStartCursor(ctx, store, stream, lastID, w, flusher)
}
lastWrite := time.Now() lastWrite := time.Now()
for { for {
@@ -104,37 +121,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,39 +163,93 @@ 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
@@ -197,39 +265,35 @@ func resolveStartCursor(ctx context.Context, client *redis.Client, stream, lastI
fmt.Fprintf(w, "event: gap\ndata: %s\n\n", string(payload)) fmt.Fprintf(w, "event: gap\ndata: %s\n\n", string(payload))
flusher.Flush() 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()
@@ -263,18 +327,3 @@ func parseStreamID(id string) (int64, int64) {
} }
return ms, seq return ms, seq
} }
// isContextTimeout returns true for the context deadline error wrapped by
// go-redis when XREAD's read context fires. We treat it the same as
// redis.Nil — i.e. an empty poll cycle, not a fatal error.
func isContextTimeout(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
// go-redis sometimes wraps this in its own error type. String match is
// a safety net.
return strings.Contains(err.Error(), "context deadline exceeded")
}
+89
View File
@@ -0,0 +1,89 @@
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}
for i, id := range []string{"R1", "R2"} {
if err := stor.SetMetadataFlat(id, map[string]string{"title": "T", "n": string(rune('a' + i))}); 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", "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"`)
if err := stor.SetProperty("R1", "title", "changed"); err != nil {
t.Fatal(err)
}
waitFor(`event: set`)
time.Sleep(300 * time.Millisecond)
cancel()
<-done
body := rec.body()
for _, k := range []string{"file:R1/title", "file:R1/n", "file:R2/title", "file:R2/n"} {
if !strings.Contains(body, `"key":"`+k+`"`) {
t.Errorf("snapshot is missing %s", k)
}
}
events := strings.Split(strings.TrimSpace(body), "\n\n")
if len(events) != 5 {
t.Fatalf("want 4 snapshot events + 1 live event, got %d:\n%s", len(events), body)
}
for i, e := range events[:3] {
if strings.Contains(e, "id: ") {
t.Errorf("snapshot event %d carries an id: %q", i, e)
}
}
if !strings.HasPrefix(events[3], "id: ") || !strings.HasPrefix(events[4], "id: ") {
t.Fatalf("the last snapshot event and the live event must carry ids:\n%s", body)
}
if !strings.Contains(events[4], `"key":"file:R1/title"`) {
t.Fatalf("live tail did not follow the snapshot: %q", events[4])
}
}
+166
View File
@@ -0,0 +1,166 @@
package api
import (
"log"
"net/http"
"sync"
"time"
"github.com/metazla/meta-core/internal/identity"
)
// statsSweepTTL is how long a keyspace-walk result stays good. The walk is
// never on the request path (see statsCache), so this only controls how stale
// the library tiles may look, not how slow the endpoint is.
const statsSweepTTL = 60 * time.Second
// fileStatsJSON is the file-backed half of the library: the roots that carry
// filePath+sizeByte+mtimeNano. Gateway / cid-rooted records have no file on
// disk and are counted in Records, not here — the gap between the two numbers
// is exactly "metadata we hold for files we do not have".
type fileStatsJSON struct {
Count int `json:"count"`
TotalSize int64 `json:"totalSize"`
}
// StatsResponse is the body of GET /api/stats — every headline number the
// dashboard shows, in one request.
//
// Deliberately split by cost. Records/RedisKeys/RedisMemory/Identities are
// cheap (an index range count, a cached row count, a file stat) plus a
// directory listing, recomputed on every call. The `redis*` JSON names
// predate the move to SQLite and are kept for dashboard compatibility:
// redisKeys is the store's row count, redisMemory its 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
// from a background-refreshed cache and are null until the first sweep lands.
type StatsResponse struct {
Records int64 `json:"records"`
RedisKeys int64 `json:"redisKeys"`
RedisMemory string `json:"redisMemory"`
Identities int `json:"identities"`
Files *fileStatsJSON `json:"files"`
UDLUsers *int `json:"udlUsers"`
// SweptAt is when the cached half was computed (unix ms), 0 if never.
SweptAt int64 `json:"sweptAt"`
// Sweeping reports that a refresh is in flight, so the UI can show the
// cached numbers as provisional instead of pretending they are live.
Sweeping bool `json:"sweeping"`
}
// statsCache holds the expensive half of /api/stats.
//
// 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
// 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
// produced and kicks off a new one if the old one is stale. `running` makes
// that single-flight, so ten tabs cost one walk.
type statsCache struct {
mu sync.Mutex
files *fileStatsJSON
udlUsers *int
sweptAt time.Time
running bool
}
// snapshot returns the cached values and whether a refresh is in flight.
func (c *statsCache) snapshot() (*fileStatsJSON, *int, time.Time, bool) {
c.mu.Lock()
defer c.mu.Unlock()
return c.files, c.udlUsers, c.sweptAt, c.running
}
// refreshIfStale starts a background sweep unless one is already running or
// the last result is still fresh. Returns true if it started one.
func (c *statsCache) refreshIfStale(sweep func() (*fileStatsJSON, *int)) bool {
c.mu.Lock()
if c.running || time.Since(c.sweptAt) < statsSweepTTL {
c.mu.Unlock()
return false
}
c.running = true
c.mu.Unlock()
go func() {
files, udl := sweep()
c.mu.Lock()
defer c.mu.Unlock()
c.running = false
// A failed sweep leaves the previous numbers in place rather than
// blanking the tiles; sweptAt still advances so a hard-failing walk
// (store down) does not spin once per poll.
c.sweptAt = time.Now()
if files != nil {
c.files = files
}
if udl != nil {
c.udlUsers = udl
}
}()
return true
}
// handleStats handles GET /api/stats.
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
resp := StatsResponse{}
if n, err := s.storage.CountRoots(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
} else {
resp.Records = n
}
if n, err := s.storage.CountKeys(); err == nil {
resp.RedisKeys = n
}
if mem, err := s.storage.GetMemoryInfo(); err == nil {
resp.RedisMemory = mem
} else {
resp.RedisMemory = "N/A"
}
if list, err := identity.List(s.config.IdentityAccountsDir()); err == nil {
resp.Identities = len(list)
}
s.stats.refreshIfStale(s.sweepStats)
files, udl, sweptAt, sweeping := s.stats.snapshot()
resp.Files = files
resp.UDLUsers = udl
resp.Sweeping = sweeping
if !sweptAt.IsZero() {
resp.SweptAt = sweptAt.UnixMilli()
}
writeJSON(w, http.StatusOK, resp)
}
// sweepStats runs the two keyspace walks behind the cached tiles. Called off
// the request path only. A failing half returns nil so the other half still
// lands.
func (s *Server) sweepStats() (*fileStatsJSON, *int) {
var files *fileStatsJSON
if tuples, err := s.storage.GetTuplesForAllFiles(); err != nil {
log.Printf("[API] stats: file tuple sweep failed: %v", err)
} else {
fs := fileStatsJSON{Count: len(tuples)}
for _, t := range tuples {
fs.TotalSize += t.Size
}
files = &fs
}
var udl *int
if stats, err := s.storage.UDLAllUserStats(); err != nil {
log.Printf("[API] stats: UDL user sweep failed: %v", err)
} else {
n := len(stats)
udl = &n
}
return files, udl
}
+19
View File
@@ -0,0 +1,19 @@
package api
import (
"path/filepath"
"testing"
"github.com/metazla/meta-core/internal/storage"
)
// newTestStorage opens a fresh metadata store in the test's temp dir.
func newTestStorage(t *testing.T) *storage.Client {
t.Helper()
c := storage.NewClient()
if err := c.Connect(filepath.Join(t.TempDir(), "meta-core.db")); err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return c
}
+210 -3
View File
@@ -7,15 +7,17 @@ package api
// meta-watch's local op-log. Records arrive already signed (and, for // meta-watch's local op-log. Records arrive already signed (and, for
// private-tier keys, already encrypted) as opaque base64 CBOR blobs; meta-core // private-tier keys, already encrypted) as opaque base64 CBOR blobs; meta-core
// version-gates and indexes them but never inspects the crypto. See // version-gates and indexes them but never inspects the crypto. See
// internal/storage/udl.go for the Redis model. // internal/storage/udl.go for the key model.
import ( import (
"encoding/json" "encoding/json"
"math" "math"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/storage"
) )
// UDLPutRequest is the body of PUT /api/udl/record. // UDLPutRequest is the body of PUT /api/udl/record.
@@ -42,12 +44,50 @@ type UDLPutResponse struct {
Accepted bool `json:"accepted"` Accepted bool `json:"accepted"`
} }
// UDLPutBatchRequest is the body of PUT /api/udl/records — many cells, one
// request. Each entry is exactly a UDLPutRequest, so a client can build the
// same signed record for either endpoint.
type UDLPutBatchRequest struct {
Records []UDLPutRequest `json:"records"`
}
// UDLPutBatchResult is one cell's outcome. Accepted=false with an empty Error
// 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 {
Key string `json:"key"`
Accepted bool `json:"accepted"`
Error string `json:"error,omitempty"`
}
// UDLPutBatchResponse mirrors the request order index-for-index.
type UDLPutBatchResponse struct {
Results []UDLPutBatchResult `json:"results"`
}
// maxUDLBatch bounds one batch. Each record is a store write 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
// UDLRecordResponse is the response of GET /api/udl/record. // UDLRecordResponse is the response of GET /api/udl/record.
type UDLRecordResponse struct { type UDLRecordResponse struct {
Record string `json:"record"` Record string `json:"record"`
Version int64 `json:"version"` Version int64 `json:"version"`
Ts int64 `json:"ts"` Ts int64 `json:"ts"`
Exists bool `json:"exists"` 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 mirrors meta-watch's Aggregate wire shape exactly (snake_case
@@ -79,12 +119,20 @@ func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
writeJSON(w, http.StatusOK, UDLRecordResponse{ resp := UDLRecordResponse{
Record: cell.Record, Record: cell.Record,
Version: cell.Version, Version: cell.Version,
Ts: cell.Ts, Ts: cell.Ts,
Exists: exists, Exists: exists,
}) }
// json.Valid guards the encoder: a malformed `value` hash field (hand-edited
// the store, 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)
} }
// handleUDLRecordPut handles PUT /api/udl/record. Version-gated: a stale write // handleUDLRecordPut handles PUT /api/udl/record. Version-gated: a stale write
@@ -124,6 +172,64 @@ func (s *Server) handleUDLRecordPut(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true}) writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true})
} }
// handleUDLRecordsPut handles PUT /api/udl/records — the batch form of
// 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) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
var req UDLPutBatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if len(req.Records) == 0 {
writeError(w, http.StatusBadRequest, "records is required")
return
}
if len(req.Records) > maxUDLBatch {
writeError(w, http.StatusBadRequest, "too many records in one batch")
return
}
results := make([]UDLPutBatchResult, 0, len(req.Records))
for _, rec := range req.Records {
if rec.Uid == "" || rec.Cid == "" || rec.Key == "" || rec.Record == "" {
results = append(results, UDLPutBatchResult{
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})
}
// handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key} // handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key}
// (active_for_user_key — My List, Continue Watching). // (active_for_user_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) {
@@ -166,6 +272,75 @@ func (s *Server) handleUDLUserCid(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries}) writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries})
} }
// handleUDLUserAll handles GET /api/udl/user/{uid}/all?after=<cid>&limit=<n>
// — every record a uid owns, for cross-instance profile recovery.
//
// 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) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
uid := mux.Vars(r)["uid"]
if uid == "" {
writeError(w, http.StatusBadRequest, "uid is required")
return
}
limit := maxUDLBatch
if v := r.URL.Query().Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "limit must be a positive integer")
return
}
if n < limit {
limit = n
}
}
entries, nextCid, err := s.storage.UDLAllForUser(uid, r.URL.Query().Get("after"), limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"entries": entries,
"nextCid": nextCid,
})
}
// handleUDLUserCount handles GET /api/udl/user/{uid}/count — how many cids a
// 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) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
uid := mux.Vars(r)["uid"]
if uid == "" {
writeError(w, http.StatusBadRequest, "uid is required")
return
}
n, err := s.storage.UDLCountUserCids(uid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
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 (uids_for_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.storage.IsConnected() {
@@ -245,3 +420,35 @@ func aggregatePublicValues(key string, values []string) UDLAggregate {
} }
return agg return agg
} }
// udlUserStatsResponse is the shape of GET /api/udl/users/stats.
type udlUserStatsResponse struct {
// Users maps uid -> counts. Accounts holding nothing are absent rather than
// present with zeroes; a caller rendering a list treats "missing" as 0.
Users map[string]storage.UDLUserStats `json:"users"`
}
// handleUDLUserStats handles GET /api/udl/users/stats — how much every account
// holds, in one keyspace walk.
//
// Bulk rather than per-uid because the only caller is the accounts page, which
// needs a number for every row: the counts come from a SCAN (there is no
// per-user cid index — see storage/udl_admin.go), and MATCH filters without
// indexing, so N per-uid calls would walk the whole keyspace N times.
//
// Deliberately not on the critical path of anything. It is an admin read whose
// cost grows with the keyspace, so callers should fetch it after rendering,
// never before.
func (s *Server) handleUDLUserStats(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeErrorSlug(w, http.StatusServiceUnavailable, "storage_unavailable",
"storage not connected", true)
return
}
stats, err := s.storage.UDLAllUserStats()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, udlUserStatsResponse{Users: stats})
}
+216
View File
@@ -0,0 +1,216 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 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 {
t.Helper()
return &Server{storage: newTestStorage(t)}
}
// getRecord drives handleUDLRecordGet and decodes the body into raw JSON
// 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()
req := httptest.NewRequest(http.MethodGet, "/api/udl/record?uid="+uid+"&cid="+cid+"&key="+key, nil)
rr := httptest.NewRecorder()
s.handleUDLRecordGet(rr, req)
var body map[string]json.RawMessage
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response %q: %v", rr.Body.String(), err)
}
return rr.Code, body
}
// The plaintext value of a PUBLIC-tier cell must come back over the wire, and
// 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()
req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(body))
rr := httptest.NewRecorder()
s.handleUDLRecordsPut(rr, req)
var out UDLPutBatchResponse
if rr.Code == http.StatusOK {
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %q: %v", rr.Body.String(), err)
}
}
return rr.Code, out
}
// **The load-bearing property of the batch endpoint: one stale cell does not
// fail the batch.**
//
// 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)
const uid = "zCarol"
const cid = "bagdsaaaseries"
// Seed s01e01 at version 4 — the batch below tries to write it at 2.
if ok, err := s.storage.UDLUpsertIfNewer(uid, cid, "seen:s01e01", 4, 100, "b2xk", "", false, false); err != nil || !ok {
t.Fatalf("seed: ok=%v err=%v", ok, err)
}
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) {
s := newUDLTestServer(t)
if code, _ := putRecords(t, s, `{"records":[]}`); code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", code)
}
}
+92
View File
@@ -0,0 +1,92 @@
package cid
import (
"encoding/base32"
"encoding/binary"
"fmt"
"strings"
"unicode/utf8"
)
// DecodeCard recovers the (source, id) pair a card locator (CodeCard, 0x1007)
// wraps. Wire form:
//
// 0x01 ‖ varint(0x1007) ‖ 0x00 (identity mh) ‖ varint(len(digest)) ‖ digest
// digest = varint(len(source)) ‖ source ‖ id (both UTF-8)
//
// Decode stops at the multihash code because ranking needs nothing past it;
// reading *which work* a card addresses needs the digest too, so this is the
// one place in Go that walks the whole thing.
//
// ⚠ Mirror of meta-read `card_cid::decode` and of meta-feeder-sdk
// `compute_card_cid`. The vectors in card_test.go are pinned literals copied
// from meta-read, not round-trips, so a drift on either side fails a test
// instead of silently reading a different work out of the same CID.
//
// Identity multihash only: a 0x1007 CID carrying any other multihash is not
// something anyone mints, and reading its digest as (source, id) would be
// reading hash bytes as text. Bytes past the declared digest length are
// ignored, as meta-read does.
func DecodeCard(cidStr string) (source, id string, err error) {
if len(cidStr) < 2 || cidStr[0] != 'b' {
return "", "", fmt.Errorf("cid %q: not a multibase base32 CIDv1", cidStr)
}
raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(cidStr[1:]))
if err != nil {
return "", "", fmt.Errorf("cid %q: base32 decode: %w", cidStr, err)
}
next := func(buf []byte, what string) (uint64, []byte, error) {
v, n := binary.Uvarint(buf)
if n <= 0 {
return 0, nil, fmt.Errorf("cid %q: bad %s varint", cidStr, what)
}
return v, buf[n:], nil
}
version, raw, err := next(raw, "version")
if err != nil {
return "", "", err
}
if version != 1 {
return "", "", fmt.Errorf("cid %q: unsupported CID version %d", cidStr, version)
}
codec, raw, err := next(raw, "codec")
if err != nil {
return "", "", err
}
if codec != CodeCard {
return "", "", fmt.Errorf("cid %q: codec 0x%x is not a card locator", cidStr, codec)
}
mh, raw, err := next(raw, "multihash-code")
if err != nil {
return "", "", err
}
if mh != 0 {
return "", "", fmt.Errorf("cid %q: card locator with non-identity multihash 0x%x", cidStr, mh)
}
digestLen, raw, err := next(raw, "digest-length")
if err != nil {
return "", "", err
}
if digestLen > uint64(len(raw)) {
return "", "", fmt.Errorf("cid %q: digest truncated", cidStr)
}
digest := raw[:digestLen]
sourceLen, rest, err := next(digest, "source-length")
if err != nil {
return "", "", err
}
if sourceLen > uint64(len(rest)) {
return "", "", fmt.Errorf("cid %q: source truncated", cidStr)
}
source, id = string(rest[:sourceLen]), string(rest[sourceLen:])
if source == "" || id == "" {
return "", "", fmt.Errorf("cid %q: empty source or id", cidStr)
}
if !utf8.ValidString(source) || !utf8.ValidString(id) {
return "", "", fmt.Errorf("cid %q: source or id is not UTF-8", cidStr)
}
return source, id, nil
}
+71
View File
@@ -0,0 +1,71 @@
package cid
import (
"encoding/base32"
"encoding/binary"
"strings"
"testing"
)
// Pinned from meta-read `src/card_cid.rs` / meta-feeder-sdk `compute_card_cid`
// — literals, not round-trips (see DecodeCard).
const (
goldenAnilistCard = "bagdsaaaua5qw42lmnfzxi3lbnztwcorrga2tonzy"
goldenMangadexCard = "bagdsaabybrwwc3thmfsgk6bon5zgol3nmfxgoyjpmiygenzsgftgmlldgm4dqljugq4dmllbmeygmlldgjrdaytcgmzdcnjrgi"
)
// wireCID builds a CIDv1 from raw parts so the negative cases below can vary
// one field at a time.
func wireCID(codec, mh uint64, digest []byte) string {
buf := []byte{0x01}
buf = binary.AppendUvarint(buf, codec)
buf = binary.AppendUvarint(buf, mh)
buf = binary.AppendUvarint(buf, uint64(len(digest)))
buf = append(buf, digest...)
return "b" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf))
}
func cardDigest(source, id string) []byte {
d := binary.AppendUvarint(nil, uint64(len(source)))
d = append(d, source...)
return append(d, id...)
}
func TestDecodeCard_GoldenVectors(t *testing.T) {
for _, tc := range []struct {
cid, source, id string
}{
{goldenAnilistCard, "anilist", "manga:105778"},
{goldenMangadexCard, "mangadex.org", "/manga/b0b721ff-c388-4486-aa0f-c2b0bb321512"},
} {
source, id, err := DecodeCard(tc.cid)
if err != nil {
t.Fatalf("DecodeCard(%s): %v", tc.cid, err)
}
if source != tc.source || id != tc.id {
t.Errorf("DecodeCard(%s) = (%q, %q), want (%q, %q)", tc.cid, source, id, tc.source, tc.id)
}
// The test encoder must agree with the pinned literal, or the negative
// cases below are testing a different wire format.
if got := wireCID(CodeCard, 0, cardDigest(tc.source, tc.id)); got != tc.cid {
t.Errorf("wireCID(%q, %q) = %s, want %s", tc.source, tc.id, got, tc.cid)
}
}
}
func TestDecodeCard_RejectsEverythingElse(t *testing.T) {
for name, s := range map[string]string{
"url locator codec": wireCID(CodeURL, 0, cardDigest("anilist", "1")),
"non-identity multihash": wireCID(CodeCard, CodeSha256, cardDigest("anilist", "1")),
"empty id": wireCID(CodeCard, 0, cardDigest("anilist", "")),
"source past digest": wireCID(CodeCard, 0, []byte{0x20, 'a'}),
"not a CID": "title:berserk",
"CIDv0": "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG",
"truncated": goldenAnilistCard[:12],
"empty": "",
} {
if source, id, err := DecodeCard(s); err == nil {
t.Errorf("%s: DecodeCard(%q) = (%q, %q), want error", name, s, source, id)
}
}
}
+25 -1
View File
@@ -22,7 +22,7 @@
"25": "nzb-posting - content-derived (Message-ID set digest), but not itself fetchable", "25": "nzb-posting - content-derived (Message-ID set digest), but not itself fetchable",
"20": "BitTorrent infohashes - swarm interop", "20": "BitTorrent infohashes - swarm interop",
"10": "midhash256 - the record address", "10": "midhash256 - the record address",
"5": "opaque locators (nzb-release, url, card) - never outrank a real digest", "5": "opaque locators (nzb-release, url, card, yt-video, ext-play) - never outrank a real digest",
"0": "weak/unknown digests, unparseable input" "0": "weak/unknown digests, unparseable input"
}, },
"tieBreak": "lexicographically smaller cid wins", "tieBreak": "lexicographically smaller cid wins",
@@ -116,6 +116,30 @@
"rank": 5, "rank": 5,
"note": "codec 0x1007 + IDENTITY multihash 0x00, digest = varint(len(source)) || source || id — here (\"tmdb\",\"tv:95479\"). Same codec-slot rule as nzb-release/url: match on the CODEC, never the mh. The limit case of the family — the only CID in MetaMesh with NO bytes behind it anywhere, ever: it addresses a *work* (a series, a film), not a file, so the byte path 404s by design and bitswap never offers it (seeding is gated on sha2-256). Tier 5 is load-bearing for the planned cross-source merge: one card record may carry BOTH a TMDB and a MyAnimeList locator, and if a real card body is ever added (see card-tier-search.md §4.3) its sha2-256 must win the canonical election over both locators without any addressing change. Ranking it 0 instead would still lose to a digest, but would put the implementations back out of agreement — which is the exact failure this fixture exists to prevent." "note": "codec 0x1007 + IDENTITY multihash 0x00, digest = varint(len(source)) || source || id — here (\"tmdb\",\"tv:95479\"). Same codec-slot rule as nzb-release/url: match on the CODEC, never the mh. The limit case of the family — the only CID in MetaMesh with NO bytes behind it anywhere, ever: it addresses a *work* (a series, a film), not a file, so the byte path 404s by design and bitswap never offers it (seeding is gated on sha2-256). Tier 5 is load-bearing for the planned cross-source merge: one card record may carry BOTH a TMDB and a MyAnimeList locator, and if a real card body is ever added (see card-tier-search.md §4.3) its sha2-256 must win the canonical election over both locators without any addressing change. Ranking it 0 instead would still lose to a digest, but would put the implementations back out of agreement — which is the exact failure this fixture exists to prevent."
}, },
{
"name": "yt-video-locator",
"cid": "bagecaaarav3gszdfn42ein3vgvfumn2tka4a",
"rank": 5,
"note": "codec 0x1008 + IDENTITY multihash 0x00, digest = varint(len(kind)) || kind || id — here (\"video\",\"4D7u5KF7SP8\"). DELEGATED PLAYBACK: the limit case beyond card. A card addresses a work with no bytes anywhere; this addresses a *rendition* whose bytes exist but are permanently someone else's — an external player renders it, nothing is transported, seeded or content-addressed. Framing is byte-identical to card (0x1007) on purpose, so each implementation extends the parser it already wrote. Same codec-slot rule: match on the CODEC, never the mh (identity 0x00 would fall through and score 0). Tier 5 is load-bearing for the on-ramp: a delegated reference and a real archived copy can sit on ONE record, and that sha2-256 must win the canonical election over this locator with no addressing change. NOTE the byte path must REFUSE this (400), not 404 — see docs/cid-formats.md §7.4."
},
{
"name": "ext-play-locator",
"cid": "bagesaabenb2hi4dthixs6zlymfwxa3dffzrgc3temnqw24bomnxw2l3bnrrhk3jppa",
"rank": 5,
"note": "codec 0x1009 + IDENTITY multihash 0x00, digest = the verbatim UTF-8 URL. WIRE-IDENTICAL to url (0x1006) apart from the codec, and that difference is the entire contract: 0x1006 means 'fetch these bytes once and seed them', 0x1009 means 'open this in a browser, there are no bytes for us'. A decoder that matched on SHAPE rather than on the codec slot would see an identity multihash wrapping an http(s) URL, take the 0x1006 path, fetch a watch/Bandcamp page, chunk it, seed the HTML and link that content CID onto the record AS THE FILE — the MSR1 poisoning shape, except permanent and replicated. Same tier/reasoning as the rest of the locator family."
},
{
"name": "provider-file-locator",
"cid": "bagfcaaa2bvxxazloon2we5djorwgk43gnfwgkorxga3dcobtgq",
"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."
},
{
"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",
+27 -1
View File
@@ -58,6 +58,31 @@ const (
// one record, and a real digest added later still wins the election. // one record, and a real digest added later still wins the election.
CodeCard = 0x1007 // custom — identity-multihash CID wrapping a card (work) id CodeCard = 0x1007 // custom — identity-multihash CID wrapping a card (work) id
// CodeYtVideo and CodeExtPlay are the DELEGATED-PLAYBACK locators — the
// limit case beyond CodeCard. A card addresses a work with no bytes
// anywhere; these address a *rendition* whose bytes exist but are
// permanently someone else's: an external player renders the media, nothing
// is transported, seeded or content-addressed.
//
// They rank on the CODEC like the rest of the family — their multihash is
// identity (0x00), so a rank that dispatched on the multihash would see 0x00,
// fall through and silently score 0. That is the exact drift that once made
// meta-share and meta-search elect different canonical CIDs for one record.
//
// Tier 5 is also what keeps the on-ramp safe: a delegated reference and a
// real CID can sit on one record, and if the media is ever archived to the
// mesh its sha2-256 wins the canonical election with no addressing change.
CodeYtVideo = 0x1008 // custom — identity-multihash CID wrapping a YouTube (kind, id)
CodeExtPlay = 0x1009 // custom — identity-multihash CID wrapping a URL to OPEN, never fetch
// CodeProviderFile is the FETCHABLE twin of CodeExtPlay: a provider-held
// file addressed as varint(len(source))‖source‖id — ("opensubtitles",
// "file:7061834"). Framing is byte-identical to CodeCard; only the byte
// path differs (it redeems the id through that source's feeder instead of
// 404ing). Same codec-slot rule and same tier: once the fetched bytes'
// sha2-256 is linked onto the record it wins with no addressing change.
CodeProviderFile = 0x100A // custom — identity-multihash CID wrapping a (source, id) provider file
// CodeNzbPosting is the INVERSE trap of the two above: its multihash is a // CodeNzbPosting is the INVERSE trap of the two above: its multihash is a
// REAL sha2-256 digest (over the normalised, sorted article Message-ID // REAL sha2-256 digest (over the normalised, sorted article Message-ID
// set of a Usenet posting we scanned ourselves — never a locator, no // set of a Usenet posting we scanned ourselves — never a locator, no
@@ -165,7 +190,8 @@ func Rank(cidStr string) int {
} }
// Opaque locators. Codec-only: their multihash is identity (0x00), so // Opaque locators. Codec-only: their multihash is identity (0x00), so
// there is nothing to match on the mh side. // there is nothing to match on the mh side.
if codec == CodeNzbRelease || codec == CodeURL || codec == CodeCard { if codec == CodeNzbRelease || codec == CodeURL || codec == CodeCard ||
codec == CodeYtVideo || codec == CodeExtPlay || codec == CodeProviderFile {
return RankLocator return RankLocator
} }
// nzb-posting: codec-only, checked BEFORE the mh switch below — its // nzb-posting: codec-only, checked BEFORE the mh switch below — its
+24 -16
View File
@@ -19,8 +19,8 @@ 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
// HTTP API configuration // HTTP API configuration
HTTPPort int // HTTP API port (default: 9000) HTTPPort int // HTTP API port (default: 9000)
@@ -40,6 +40,14 @@ type Config struct {
// Mount configuration // Mount configuration
MountsDir string // Path to mounts configuration (default: /meta-core/mounts) MountsDir string // Path to mounts configuration (default: /meta-core/mounts)
// UDP discovery (beacon v2). Replaces the file-based registry that used
// to live under /meta-core/services; see
// docs/project-architecture/beacon-v2.md.
EnableUDPDiscovery bool // Advertise + listen on the multicast group (default: true)
DiscoveryGroup string // BEACON_GROUP: IPv4 multicast group (default: 239.255.99.1)
DiscoveryPort int // BEACON_PORT: UDP port (default: 9099)
DiscoveryIntervalMS int // BEACON_INTERVAL_MS: unsolicited advertise interval (default: 10000)
} }
// Load creates a Config from environment variables // Load creates a Config from environment variables
@@ -52,7 +60,7 @@ 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", ""),
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),
@@ -63,6 +71,10 @@ func Load() *Config {
WatchIntervalMS: getEnvInt("WATCH_INTERVAL_MS", 1000), WatchIntervalMS: getEnvInt("WATCH_INTERVAL_MS", 1000),
DebounceMS: getEnvInt("DEBOUNCE_MS", 30000), DebounceMS: getEnvInt("DEBOUNCE_MS", 30000),
EnableFileWatcher: getEnvBool("ENABLE_FILE_WATCHER", true), EnableFileWatcher: getEnvBool("ENABLE_FILE_WATCHER", true),
EnableUDPDiscovery: getEnvBool("ENABLE_UDP_DISCOVERY", true),
DiscoveryGroup: getEnv("BEACON_GROUP", "239.255.99.1"),
DiscoveryPort: getEnvInt("BEACON_PORT", 9099),
DiscoveryIntervalMS: getEnvInt("BEACON_INTERVAL_MS", 10000),
} }
// Set mounts directory // Set mounts directory
@@ -71,24 +83,20 @@ func Load() *Config {
return cfg return cfg
} }
// LockFilePath returns the path to the leader lock file // 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
// 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"
} }
// InfoFilePath returns the path to the leader info file // DBPath returns the path of meta-core's SQLite database file.
func (c *Config) InfoFilePath() string { func (c *Config) DBPath() string {
return c.MetaCorePath + "/locks/kv-leader.info" if c.DBPathOverride != "" {
return c.DBPathOverride
} }
return c.MetaCorePath + "/db/meta-core.db"
// RedisDataDir returns the path to Redis data directory
func (c *Config) RedisDataDir() string {
return c.MetaCorePath + "/db/redis"
}
// ServicesDir returns the path to services directory
func (c *Config) ServicesDir() string {
return c.MetaCorePath + "/services"
} }
// MountsFilePath returns the path to the mounts configuration file // MountsFilePath returns the path to the mounts configuration file
-196
View File
@@ -1,196 +0,0 @@
package discovery
import (
"encoding/json"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/metazla/meta-core/internal/config"
)
// CleanupStats holds statistics about the cleanup process
type CleanupStats struct {
LastRunTime string `json:"lastRunTime,omitempty"`
ServicesRemoved int `json:"servicesRemoved"`
TotalRuns int `json:"totalRuns"`
Errors int `json:"errors"`
}
// Cleaner handles periodic cleanup of dead service registration files
type Cleaner struct {
config *config.Config
servicesDir string
ownFile string // Our own service file (never delete)
stats CleanupStats
stopChan chan struct{}
wg sync.WaitGroup
mu sync.RWMutex
}
// NewCleaner creates a new service cleaner
func NewCleaner(cfg *config.Config) *Cleaner {
hostname, _ := os.Hostname()
return &Cleaner{
config: cfg,
servicesDir: cfg.ServicesDir(),
ownFile: filepath.Join(cfg.ServicesDir(), cfg.ServiceName+"-"+hostname+".json"),
stopChan: make(chan struct{}),
}
}
// Start begins the cleanup loop
func (c *Cleaner) Start() error {
log.Printf("[Cleaner] Starting dead service cleanup (interval: %dms, threshold: %dms)",
c.config.CleanupIntervalMS, c.config.DeadServiceThresholdMS)
c.wg.Add(1)
go c.cleanupLoop()
return nil
}
// Stop stops the cleanup loop
func (c *Cleaner) Stop() error {
log.Println("[Cleaner] Stopping dead service cleanup...")
close(c.stopChan)
c.wg.Wait()
return nil
}
// cleanupLoop runs the periodic cleanup
func (c *Cleaner) cleanupLoop() {
defer c.wg.Done()
// Run an initial cleanup shortly after starting
initialDelay := time.NewTimer(30 * time.Second)
select {
case <-c.stopChan:
initialDelay.Stop()
return
case <-initialDelay.C:
c.runCleanup()
}
ticker := time.NewTicker(time.Duration(c.config.CleanupIntervalMS) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-c.stopChan:
return
case <-ticker.C:
c.runCleanup()
}
}
}
// runCleanup scans the services directory and removes dead services
func (c *Cleaner) runCleanup() {
c.mu.Lock()
c.stats.TotalRuns++
c.stats.LastRunTime = time.Now().UTC().Format(time.RFC3339)
c.mu.Unlock()
entries, err := os.ReadDir(c.servicesDir)
if err != nil {
if !os.IsNotExist(err) {
log.Printf("[Cleaner] Failed to read services directory: %v", err)
c.mu.Lock()
c.stats.Errors++
c.mu.Unlock()
}
return
}
threshold := time.Duration(c.config.DeadServiceThresholdMS) * time.Millisecond
removedCount := 0
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
filePath := filepath.Join(c.servicesDir, entry.Name())
// Never delete our own service file
if filePath == c.ownFile {
continue
}
// Check if service is dead
isDead, serviceName, age := c.isServiceDead(filePath, threshold)
if isDead {
if err := os.Remove(filePath); err != nil {
log.Printf("[Cleaner] Failed to remove dead service file %s: %v", entry.Name(), err)
c.mu.Lock()
c.stats.Errors++
c.mu.Unlock()
} else {
ageMinutes := age.Minutes()
log.Printf("[Cleaner] Removed dead service: %s (last heartbeat: %.1f minutes ago)", serviceName, ageMinutes)
removedCount++
}
}
}
if removedCount > 0 {
c.mu.Lock()
c.stats.ServicesRemoved += removedCount
c.mu.Unlock()
log.Printf("[Cleaner] Cleanup complete: removed %d dead service(s)", removedCount)
}
}
// isServiceDead checks if a service file represents a dead service
// Returns: isDead, serviceName, age since last heartbeat
func (c *Cleaner) isServiceDead(filePath string, threshold time.Duration) (bool, string, time.Duration) {
data, err := os.ReadFile(filePath)
if err != nil {
// Can't read file - treat as dead to clean up corrupted files
log.Printf("[Cleaner] Cannot read service file %s: %v (treating as dead)", filepath.Base(filePath), err)
return true, filepath.Base(filePath), threshold
}
var info ServiceInfo
if err := json.Unmarshal(data, &info); err != nil {
// Corrupted JSON - skip rather than delete (might be recoverable)
log.Printf("[Cleaner] Skipping corrupted service file %s: %v", filepath.Base(filePath), err)
return false, "", 0
}
// Parse last heartbeat timestamp
if info.LastHeartbeat == "" {
// No heartbeat timestamp - treat as dead
return true, info.Name, threshold
}
lastHeartbeat, err := time.Parse(time.RFC3339, info.LastHeartbeat)
if err != nil {
// Invalid timestamp - treat as dead
log.Printf("[Cleaner] Invalid timestamp in service file %s: %v (treating as dead)", filepath.Base(filePath), err)
return true, info.Name, threshold
}
age := time.Since(lastHeartbeat)
if age > threshold {
return true, info.Name, age
}
return false, info.Name, age
}
// Stats returns a copy of the current cleanup statistics
func (c *Cleaner) Stats() CleanupStats {
c.mu.RLock()
defer c.mu.RUnlock()
return CleanupStats{
LastRunTime: c.stats.LastRunTime,
ServicesRemoved: c.stats.ServicesRemoved,
TotalRuns: c.stats.TotalRuns,
Errors: c.stats.Errors,
}
}
-286
View File
@@ -1,286 +0,0 @@
package discovery
import (
"encoding/json"
"fmt"
"log"
"net"
"os"
"path/filepath"
"sync"
"time"
"github.com/metazla/meta-core/internal/config"
)
// ServiceInfo contains simplified service registration data
// URLs are obtained via the /urls API endpoint
type ServiceInfo struct {
Name string `json:"name"`
Hostname string `json:"hostname"`
BaseUrl string `json:"baseUrl"`
Status string `json:"status"`
LastHeartbeat string `json:"lastHeartbeat"`
}
// Service handles service registration and discovery
type Service struct {
config *config.Config
servicesDir string
serviceFile string
info *ServiceInfo
mu sync.RWMutex
stopChan chan struct{}
wg sync.WaitGroup
}
// NewService creates a new service discovery instance
func NewService(cfg *config.Config) *Service {
hostname, _ := os.Hostname()
return &Service{
config: cfg,
servicesDir: cfg.ServicesDir(),
serviceFile: filepath.Join(cfg.ServicesDir(), cfg.ServiceName+"-"+hostname+".json"),
stopChan: make(chan struct{}),
}
}
// Start begins service registration and heartbeat
func (s *Service) Start() error {
log.Printf("[Discovery] Starting service discovery for %s", s.config.ServiceName)
// Ensure services directory exists
if err := os.MkdirAll(s.servicesDir, 0755); err != nil {
return fmt.Errorf("failed to create services directory: %w", err)
}
// Build and register service info
s.info = s.buildServiceInfo()
if err := s.register(); err != nil {
return fmt.Errorf("failed to register service: %w", err)
}
// Start heartbeat loop
s.wg.Add(1)
go s.heartbeatLoop()
return nil
}
// Stop stops service discovery and unregisters
func (s *Service) Stop() error {
log.Println("[Discovery] Stopping service discovery...")
close(s.stopChan)
s.wg.Wait()
// Unregister by removing service file
if err := os.Remove(s.serviceFile); err != nil && !os.IsNotExist(err) {
log.Printf("[Discovery] Failed to remove service file: %v", err)
}
return nil
}
// buildServiceInfo creates the service info for this instance
func (s *Service) buildServiceInfo() *ServiceInfo {
hostname, _ := os.Hostname()
ip := getLocalIP()
// Browser-facing nav URL: prefer META_CORE_PUBLIC_URL (the reachable URL
// when there's no Caddy perimeter in front — e.g. a debug-direct port),
// then BASE_URL (the Caddy perimeter URL), then the internal IP.
var baseUrl string
if s.config.PublicURL != "" {
baseUrl = s.config.PublicURL
} else if s.config.BaseURL != "" {
baseUrl = s.config.BaseURL
} else {
baseUrl = fmt.Sprintf("http://%s:%d", ip, s.config.APIPort)
}
info := &ServiceInfo{
Name: s.config.ServiceName,
Hostname: hostname,
BaseUrl: baseUrl,
Status: "running",
LastHeartbeat: time.Now().UTC().Format(time.RFC3339),
}
return info
}
// register writes service info to file
func (s *Service) register() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.writeServiceInfo(s.info)
}
// writeServiceInfo atomically writes service info to file
func (s *Service) writeServiceInfo(info *ServiceInfo) error {
tempPath := s.serviceFile + ".tmp"
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(tempPath, data, 0644); err != nil {
return err
}
return os.Rename(tempPath, s.serviceFile)
}
// heartbeatLoop periodically updates the last heartbeat
func (s *Service) heartbeatLoop() {
defer s.wg.Done()
ticker := time.NewTicker(time.Duration(s.config.HeartbeatIntervalMS) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-s.stopChan:
return
case <-ticker.C:
s.heartbeat()
}
}
}
// heartbeat updates the last heartbeat timestamp
func (s *Service) heartbeat() {
s.mu.Lock()
defer s.mu.Unlock()
if s.info == nil {
return
}
s.info.LastHeartbeat = time.Now().UTC().Format(time.RFC3339)
if err := s.writeServiceInfo(s.info); err != nil {
log.Printf("[Discovery] Failed to update heartbeat: %v", err)
}
}
// UpdateStatus updates the service status
func (s *Service) UpdateStatus(status string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.info == nil {
return fmt.Errorf("service not registered")
}
s.info.Status = status
s.info.LastHeartbeat = time.Now().UTC().Format(time.RFC3339)
return s.writeServiceInfo(s.info)
}
// Discover finds a service by name
// Looks for files matching pattern: name-*.json (hostname-based naming)
func (s *Service) Discover(name string) (*ServiceInfo, error) {
// First try exact match for backward compatibility
exactPath := filepath.Join(s.servicesDir, name+".json")
if data, err := os.ReadFile(exactPath); err == nil {
var info ServiceInfo
if err := json.Unmarshal(data, &info); err == nil {
return s.checkStale(&info), nil
}
}
// Search for hostname-based files: name-*.json
pattern := filepath.Join(s.servicesDir, name+"-*.json")
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
// Return the first valid service found
for _, match := range matches {
data, err := os.ReadFile(match)
if err != nil {
continue
}
var info ServiceInfo
if err := json.Unmarshal(data, &info); err != nil {
continue
}
return s.checkStale(&info), nil
}
return nil, nil
}
// checkStale marks a service as stale if heartbeat is too old
func (s *Service) checkStale(info *ServiceInfo) *ServiceInfo {
lastHeartbeat, err := time.Parse(time.RFC3339, info.LastHeartbeat)
if err == nil {
staleThreshold := time.Duration(s.config.StaleThresholdMS) * time.Millisecond
if time.Since(lastHeartbeat) > staleThreshold {
info.Status = "stale"
}
}
return info
}
// DiscoverAll finds all registered services
func (s *Service) DiscoverAll() ([]*ServiceInfo, error) {
entries, err := os.ReadDir(s.servicesDir)
if err != nil {
if os.IsNotExist(err) {
return []*ServiceInfo{}, nil
}
return nil, err
}
var services []*ServiceInfo
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
filePath := filepath.Join(s.servicesDir, entry.Name())
data, err := os.ReadFile(filePath)
if err != nil {
log.Printf("[Discovery] Failed to read service file %s: %v", entry.Name(), err)
continue
}
var info ServiceInfo
if err := json.Unmarshal(data, &info); err != nil {
log.Printf("[Discovery] Failed to parse service file %s: %v", entry.Name(), err)
continue
}
services = append(services, s.checkStale(&info))
}
return services, nil
}
// getLocalIP returns the local IP address
func getLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
hostname, _ := os.Hostname()
return hostname
}
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
return ipnet.IP.String()
}
}
}
hostname, _ := os.Hostname()
return hostname
}
-222
View 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
}
+126
View File
@@ -0,0 +1,126 @@
package identity
// In-memory, single-use challenge store backing proof-of-possession on the
// account operations that need it (reveal, delete).
//
// In memory on purpose: a challenge is worthless once redeemed and worthless
// once expired, so persisting it would only widen the window in which a leaked
// one is useful. A meta-core restart invalidates every challenge in flight,
// which costs a client one retry.
//
// Mirrors the sign-in challenge in meta-watch's src/auth.rs, down to the TTL —
// the browser signs immediately, so seconds is the right order and a narrow
// window bounds replay if one ever surfaces in a proxy log.
import (
"errors"
"sync"
"time"
)
// ChallengeTTL is how long an issued challenge stays answerable.
const ChallengeTTL = 120 * time.Second
// maxChallenges caps the store. An unauthenticated caller can mint challenges
// for any uid it can name, so the map is attacker-growable; without a ceiling
// that is a memory-exhaustion lever. At the cap, issuing fails rather than
// evicting: dropping someone else's live challenge on demand would let an
// attacker deny sign-outs, which is worse than making them retry.
const maxChallenges = 4096
var (
// ErrChallengeUnknown covers absent, already-redeemed and expired alike.
// One error for all three deliberately: distinguishing them tells a prober
// whether a challenge string was ever real.
ErrChallengeUnknown = errors.New("challenge is unknown, already used, or expired")
ErrChallengeFull = errors.New("too many challenges in flight; try again shortly")
)
type challengeEntry struct {
uid string
action Action
expires time.Time
}
// ChallengeStore issues and redeems challenges. Safe for concurrent use.
type ChallengeStore struct {
mu sync.Mutex
entries map[string]challengeEntry
ttl time.Duration
// now is swappable so the tests can age entries without sleeping.
now func() time.Time
}
func NewChallengeStore() *ChallengeStore {
return &ChallengeStore{
entries: make(map[string]challengeEntry),
ttl: ChallengeTTL,
now: time.Now,
}
}
// Issue mints a challenge for (uid, action) and remembers it until redeemed or
// expired. Returns the literal text the client signs and its expiry.
func (s *ChallengeStore) Issue(uid string, action Action) (string, time.Time, error) {
if !validUID(uid) {
return "", time.Time{}, errors.New("invalid uid")
}
if !ValidAction(action) {
return "", time.Time{}, errors.New("unknown action")
}
text, err := NewChallengeText(uid, action)
if err != nil {
return "", time.Time{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
s.sweepLocked()
if len(s.entries) >= maxChallenges {
return "", time.Time{}, ErrChallengeFull
}
expires := s.now().Add(s.ttl)
s.entries[text] = challengeEntry{uid: uid, action: action, expires: expires}
return text, expires, nil
}
// Redeem consumes a challenge, asserting it was issued for this exact uid and
// action. Single-use: the entry is dropped on any lookup that finds it, so a
// mismatched redemption burns the challenge rather than leaving it to be
// retried against a different account.
func (s *ChallengeStore) Redeem(text, uid string, action Action) error {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.entries[text]
if !ok {
return ErrChallengeUnknown
}
delete(s.entries, text)
if s.now().After(entry.expires) {
return ErrChallengeUnknown
}
if entry.uid != uid || entry.action != action {
return ErrChallengeUnknown
}
// Re-read the signed bytes rather than trusting the map key alone: the
// signature covers `text`, so `text` is what has to name this uid+action.
if !ChallengeMatches(text, uid, action) {
return ErrChallengeUnknown
}
return nil
}
// sweepLocked drops expired entries. Caller holds the lock.
//
// Called only from Issue: with no background goroutine, expiry is enforced
// lazily on the one path that grows the map, which is exactly where the bound
// matters.
func (s *ChallengeStore) sweepLocked() {
now := s.now()
for text, e := range s.entries {
if now.After(e.expires) {
delete(s.entries, text)
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package identity
import (
"encoding/base64"
"testing"
)
// A real signature produced by @noble/secp256k1 v3 — the library the dashboard
// and meta-watch both sign with — pinned as a fixture and verified here.
//
// This is the one seam neither side's own tests can cover: Go signs DER, the
// browser signs 64-byte compact, and every unit test above verifies signatures
// this package produced itself. If the encodings ever disagree, the gate fails
// closed in production (nobody can delete their own account) while every other
// test stays green. Regenerate with:
//
// node -e 'import("@noble/secp256k1").then(async s => { … })'
//
// against the same fixed scalar (32 zero bytes with 42 in the last).
func TestVerify_AcceptsARealNobleV3Signature(t *testing.T) {
const (
uid = "ztbJ71eLvfmGXw2ydaSPHavfwjva5Bzadutw2tvYDVFVY"
challenge = "metamesh-identity-v1:delete:ztbJ71eLvfmGXw2ydaSPHavfwjva5Bzadutw2tvYDVFVY:deadbeef"
sigB64 = "lIiZGL0j9l/w2WXwzwe56cICnusuYdD3oaTuYfFTtNJBR9MolXFPk3014WQeAlvmbX4LfnW+TQz4asqoSwmSAQ=="
)
sig, err := base64.StdEncoding.DecodeString(sigB64)
if err != nil {
t.Fatalf("decode fixture: %v", err)
}
if len(sig) != 64 {
t.Fatalf("fixture is %d bytes, expected noble's 64-byte compact form", len(sig))
}
if err := Verify(uid, []byte(challenge), sig); err != nil {
t.Fatalf("a genuine browser signature must verify: %v", err)
}
// And the same signature must not verify over a different challenge — a
// verifier that ignored the payload would pass the line above too.
if err := Verify(uid, []byte(challenge+"x"), sig); err == nil {
t.Fatal("the fixture verified over the wrong payload")
}
}
+160
View File
@@ -0,0 +1,160 @@
package identity
// Signature verification against a uid, and the challenge vocabulary that
// makes a signature mean "I hold this account's private key" rather than
// "meta-core signed something for me".
//
// The asymmetry that motivates this file: meta-core *holds* every private key,
// so it can sign as anybody. A signature is therefore only proof of possession
// if the payload is one meta-core refuses to sign on request — hence
// ChallengeDomain, which POST /api/identity/sign rejects outright. Without that
// refusal an attacker mints their own authorisation in one extra call and the
// whole gate is decoration.
//
// Verification itself needs no stored state: a uid *is* the public key
// ("z" + base58btc(33-byte compressed sec1)), so the pubkey is recovered from
// the uid the caller names. Mirrors `verify_local` / `verify_local_compact` in
// meta-watch's src/record/sign.rs — same curve, same digest, both signature
// encodings.
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"strings"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
"github.com/mr-tron/base58"
)
// ChallengeDomain prefixes every byte string that authorises an account
// operation. POST /api/identity/sign and /sign-batch refuse payloads carrying
// it, so the only way to produce one is to hold the key yourself.
//
// Versioned because it is a wire commitment: changing it invalidates every
// challenge in flight, which is harmless, but changing it *silently* would let
// an old-domain payload keep working. Bump it deliberately.
const ChallengeDomain = "metamesh-identity-v1:"
// Action is the operation a challenge authorises. A challenge minted to reveal
// a key must not also delete the account, so the action is inside the signed
// bytes and is re-checked at redemption.
type Action string
const (
ActionReveal Action = "reveal"
ActionDelete Action = "delete"
)
// ValidAction reports whether a is an action a challenge may be issued for.
func ValidAction(a Action) bool {
return a == ActionReveal || a == ActionDelete
}
// ValidUID exposes the uid shape check to callers outside this package (the
// API layer, which must reject a hostile uid before it reaches a key-range read
// pattern or a file path).
func ValidUID(uid string) bool { return validUID(uid) }
// IsChallengePayload reports whether b is (or claims to be) an account
// authorisation payload. Used by the signing endpoints to refuse minting one.
//
// Deliberately a prefix test on raw bytes rather than a parse: anything that
// merely *looks* like a challenge is refused, so a malformed or future-version
// payload fails closed instead of being signed.
func IsChallengePayload(b []byte) bool {
return strings.HasPrefix(string(b), ChallengeDomain)
}
// PubkeyFromUID recovers the compressed public key a uid encodes.
func PubkeyFromUID(uid string) (*secp256k1.PublicKey, error) {
if !validUID(uid) {
return nil, fmt.Errorf("invalid uid: %q", uid)
}
if !strings.HasPrefix(uid, "z") {
return nil, errors.New("uid is not multibase base58btc (missing 'z' prefix)")
}
raw, err := base58.Decode(uid[1:])
if err != nil {
return nil, fmt.Errorf("uid is not valid base58: %w", err)
}
if len(raw) != 33 {
return nil, fmt.Errorf("uid decodes to %d bytes, want a 33-byte compressed pubkey", len(raw))
}
pub, err := secp256k1.ParsePubKey(raw)
if err != nil {
return nil, fmt.Errorf("uid is not a point on secp256k1: %w", err)
}
return pub, nil
}
// Verify checks sig over SHA-256(payload) against the public key inside uid.
//
// Accepts both encodings in circulation: DER (what Sign emits, and what User
// Data Layer records carry) and 64-byte compact r‖s (what the browser emits —
// noble-secp256k1 v3 dropped DER, and meta-watch's sign-in already sends
// compact). Rejecting either would break one of the two callers.
func Verify(uid string, payload, sig []byte) error {
pub, err := PubkeyFromUID(uid)
if err != nil {
return err
}
parsed, err := parseSignature(sig)
if err != nil {
return err
}
digest := sha256.Sum256(payload)
if !parsed.Verify(digest[:], pub) {
return errors.New("signature does not verify for this uid")
}
return nil
}
func parseSignature(sig []byte) (*ecdsa.Signature, error) {
if len(sig) == 64 {
var r, s secp256k1.ModNScalar
// SetByteSlice reports overflow (>= curve order), which is not a valid
// scalar. A zero r or s is likewise never produced by a real signer.
if r.SetByteSlice(sig[:32]) || r.IsZero() {
return nil, errors.New("compact signature has an invalid r")
}
if s.SetByteSlice(sig[32:]) || s.IsZero() {
return nil, errors.New("compact signature has an invalid s")
}
return ecdsa.NewSignature(&r, &s), nil
}
parsed, err := ecdsa.ParseDERSignature(sig)
if err != nil {
return nil, fmt.Errorf("signature is neither 64-byte compact nor valid DER: %w", err)
}
return parsed, nil
}
// NewChallengeText mints the literal string a client signs. The uid and action
// are inside it, so a signature cannot be lifted from one account or operation
// onto another even if the nonce leaks.
func NewChallengeText(uid string, action Action) (string, error) {
nonce := make([]byte, 32)
if _, err := rand.Read(nonce); err != nil {
return "", fmt.Errorf("read nonce: %w", err)
}
return ChallengeDomain + string(action) + ":" + uid + ":" + hex.EncodeToString(nonce), nil
}
// ChallengeMatches reports whether text is a well-formed challenge for exactly
// this uid and action. The store already keys on the full text, so this is a
// belt-and-braces re-read of the signed bytes themselves: it is the signed
// content, not the lookup key, that a verifier must agree with.
//
// Compared in constant time — the text is a bearer secret until redeemed.
func ChallengeMatches(text, uid string, action Action) bool {
want := ChallengeDomain + string(action) + ":" + uid + ":"
if len(text) <= len(want) {
return false
}
return subtle.ConstantTimeCompare([]byte(text[:len(want)]), []byte(want)) == 1
}
+278
View File
@@ -0,0 +1,278 @@
package identity
import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"time"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)
// The whole gate rests on a uid being the public key: verification consults no
// stored state, so a signature is checked against the account the caller names
// rather than against whatever key meta-core happens to hold.
func TestVerify_AcceptsDERFromOurOwnSigner(t *testing.T) {
id, err := Generate()
if err != nil {
t.Fatalf("generate: %v", err)
}
payload := []byte(ChallengeDomain + "delete:" + id.UID + ":abcdef")
sig, err := Sign(id, payload)
if err != nil {
t.Fatalf("sign: %v", err)
}
if err := Verify(id.UID, payload, sig); err != nil {
t.Fatalf("DER signature from Sign must verify: %v", err)
}
}
// The browser is the signer that matters here, and noble-secp256k1 v3 emits
// 64-byte compact r‖s — it dropped DER entirely. A verifier that took only DER
// would reject every real proof while passing this package's own tests.
func TestVerify_AcceptsCompactAsTheBrowserEmitsIt(t *testing.T) {
id, err := Generate()
if err != nil {
t.Fatalf("generate: %v", err)
}
payload := []byte("some challenge text")
raw, _ := hex.DecodeString(id.PrivateKeyHex)
priv := secp256k1.PrivKeyFromBytes(raw)
digest := sha256.Sum256(payload)
der := ecdsa.Sign(priv, digest[:])
// Re-encode the same signature compactly, the way the browser would.
parsed, err := ecdsa.ParseDERSignature(der.Serialize())
if err != nil {
t.Fatalf("parse own DER: %v", err)
}
r := parsed.R()
sc := parsed.S()
compact := make([]byte, 0, 64)
rb := r.Bytes()
sb := sc.Bytes()
compact = append(compact, rb[:]...)
compact = append(compact, sb[:]...)
if err := Verify(id.UID, payload, compact); err != nil {
t.Fatalf("compact signature must verify: %v", err)
}
}
func TestVerify_RejectsAnotherAccountsSignature(t *testing.T) {
alice, _ := Generate()
mallory, _ := Generate()
payload := []byte("challenge for alice")
sig, err := Sign(mallory, payload)
if err != nil {
t.Fatalf("sign: %v", err)
}
if err := Verify(alice.UID, payload, sig); err == nil {
t.Fatal("a signature by mallory must not verify as alice")
}
}
func TestVerify_RejectsTamperedPayload(t *testing.T) {
id, _ := Generate()
sig, _ := Sign(id, []byte("delete account A"))
if err := Verify(id.UID, []byte("delete account B"), sig); err == nil {
t.Fatal("a signature must not verify over different bytes")
}
}
func TestVerify_RejectsGarbageSignatureEncodings(t *testing.T) {
id, _ := Generate()
for name, sig := range map[string][]byte{
"empty": {},
"short": {1, 2, 3},
"zero compact": make([]byte, 64),
"not der": []byte("this is not a signature at all......."),
"63 bytes": make([]byte, 63),
"65 bytes non-der": make([]byte, 65),
} {
if err := Verify(id.UID, []byte("x"), sig); err == nil {
t.Errorf("%s: expected rejection", name)
}
}
}
func TestPubkeyFromUID_RejectsMalformedUIDs(t *testing.T) {
id, _ := Generate()
for name, uid := range map[string]string{
"empty": "",
"no z prefix": id.UID[1:],
"truncated": id.UID[:20],
"path traversal": "../../etc/passwd",
"with slash": "z/" + id.UID,
"not on curve": "z" + strings.Repeat("1", 45),
} {
if _, err := PubkeyFromUID(uid); err == nil {
t.Errorf("%s (%q): expected rejection", name, uid)
}
}
if _, err := PubkeyFromUID(id.UID); err != nil {
t.Fatalf("a real uid must parse: %v", err)
}
}
// The refusal that makes proof-of-possession mean anything on a node holding
// every private key. If this ever returns false for a challenge, an attacker
// asks /api/identity/sign to authorise their own delete.
func TestIsChallengePayload_CatchesAnythingInTheDomain(t *testing.T) {
yes := [][]byte{
[]byte(ChallengeDomain),
[]byte(ChallengeDomain + "delete:zAlice:beef"),
[]byte(ChallengeDomain + "some-future-action:whatever"),
[]byte("metamesh-identity-v1:"), // literal, in case the const drifts
}
for _, b := range yes {
if !IsChallengePayload(b) {
t.Errorf("must refuse to sign %q", b)
}
}
no := [][]byte{
{},
[]byte("metamesh-identity-v2:delete:zAlice"), // different domain, not ours to refuse
[]byte("a record payload"),
[]byte(" " + ChallengeDomain), // not a prefix
}
for _, b := range no {
if IsChallengePayload(b) {
t.Errorf("must still sign %q", b)
}
}
}
func TestChallengeMatches_TiesTheTextToUIDAndAction(t *testing.T) {
text, err := NewChallengeText("zAlice", ActionDelete)
if err != nil {
t.Fatalf("mint: %v", err)
}
if !ChallengeMatches(text, "zAlice", ActionDelete) {
t.Fatal("its own uid+action must match")
}
if ChallengeMatches(text, "zBob", ActionDelete) {
t.Fatal("another uid must not match")
}
if ChallengeMatches(text, "zAlice", ActionReveal) {
t.Fatal("another action must not match")
}
// A prefix-only uid must not slip through: "zAlic" is a prefix of "zAlice",
// and a naive HasPrefix on the uid alone would accept it.
if ChallengeMatches(text, "zAlic", ActionDelete) {
t.Fatal("a uid that is a prefix of the real one must not match")
}
}
func TestNewChallengeText_IsUnpredictable(t *testing.T) {
seen := make(map[string]struct{}, 64)
for i := 0; i < 64; i++ {
text, err := NewChallengeText("zAlice", ActionDelete)
if err != nil {
t.Fatalf("mint: %v", err)
}
if _, dup := seen[text]; dup {
t.Fatal("challenge repeated — the nonce is not random")
}
seen[text] = struct{}{}
}
}
// --- challenge store --------------------------------------------------------
func TestChallengeStore_RedeemIsSingleUse(t *testing.T) {
s := NewChallengeStore()
text, _, err := s.Issue("zAlice", ActionDelete)
if err != nil {
t.Fatalf("issue: %v", err)
}
if err := s.Redeem(text, "zAlice", ActionDelete); err != nil {
t.Fatalf("first redeem: %v", err)
}
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
t.Fatal("a challenge must not be redeemable twice — that is replay")
}
}
// A challenge minted to *look at* a key must not delete the account, and one
// minted for Alice must not act on Bob. Both are inside the signed text, so a
// store that ignored them would authorise the wrong operation with a perfectly
// valid signature.
func TestChallengeStore_RedeemRefusesWrongUIDOrAction(t *testing.T) {
s := NewChallengeStore()
text, _, _ := s.Issue("zAlice", ActionReveal)
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
t.Fatal("a reveal challenge must not authorise a delete")
}
text2, _, _ := s.Issue("zAlice", ActionDelete)
if err := s.Redeem(text2, "zBob", ActionDelete); err == nil {
t.Fatal("alice's challenge must not authorise deleting bob")
}
}
// A failed redemption still consumes the challenge, so a wrong guess costs a
// round trip rather than leaving the nonce up for another attempt.
func TestChallengeStore_FailedRedeemStillBurnsIt(t *testing.T) {
s := NewChallengeStore()
text, _, _ := s.Issue("zAlice", ActionDelete)
_ = s.Redeem(text, "zBob", ActionDelete)
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
t.Fatal("a burnt challenge must stay burnt for its rightful owner too")
}
}
func TestChallengeStore_Expires(t *testing.T) {
s := NewChallengeStore()
now := time.Unix(1_700_000_000, 0)
s.now = func() time.Time { return now }
text, expires, _ := s.Issue("zAlice", ActionDelete)
if !expires.Equal(now.Add(ChallengeTTL)) {
t.Fatalf("expiry %v, want %v", expires, now.Add(ChallengeTTL))
}
now = now.Add(ChallengeTTL + time.Second)
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
t.Fatal("an expired challenge must not redeem")
}
}
// Issuing is unauthenticated, so the map is attacker-growable. The cap is what
// stops that being a memory-exhaustion lever; expiry sweeping is what stops the
// cap being reached by honest traffic.
func TestChallengeStore_CapsInFlightAndSweepsExpired(t *testing.T) {
s := NewChallengeStore()
now := time.Unix(1_700_000_000, 0)
s.now = func() time.Time { return now }
for i := 0; i < maxChallenges; i++ {
if _, _, err := s.Issue("zAlice", ActionDelete); err != nil {
t.Fatalf("issue %d: %v", i, err)
}
}
if _, _, err := s.Issue("zAlice", ActionDelete); err == nil {
t.Fatal("expected the store to refuse once full")
}
// Once they age out, the next issue sweeps and succeeds again.
now = now.Add(ChallengeTTL + time.Second)
if _, _, err := s.Issue("zAlice", ActionDelete); err != nil {
t.Fatalf("after expiry the store must accept again: %v", err)
}
}
func TestChallengeStore_RefusesInvalidInputs(t *testing.T) {
s := NewChallengeStore()
if _, _, err := s.Issue("../etc", ActionDelete); err == nil {
t.Fatal("a uid that is not uid-shaped must be refused")
}
if _, _, err := s.Issue("zAlice", Action("sudo")); err == nil {
t.Fatal("an unknown action must be refused")
}
}
+827
View File
@@ -0,0 +1,827 @@
// 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)
}
// 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
}
w, err := sql.Open("sqlite", dsn(path, opts.WriterCacheMB, false))
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 _, 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))
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()
for _, p := range []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 *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) string {
q := url.Values{}
q.Add("_pragma", "busy_timeout(30000)")
q.Add("_pragma", "journal_mode(WAL)")
q.Add("_pragma", "synchronous(NORMAL)")
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}
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)
}
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)
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
}
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.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
}
+362
View File
@@ -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)
}
}
+356
View File
@@ -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)
}
+439
View File
@@ -0,0 +1,439 @@
package meshdisco
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"os"
"sort"
"sync"
"syscall"
"time"
"golang.org/x/net/ipv4"
"golang.org/x/sys/unix"
)
// Config configures a Node. Payload is a function rather than a value so the
// advertise is rebuilt from live state on every tick — that is what keeps the
// advertised core URLs and GET /urls from drifting apart.
type Config struct {
Name string
Instance string
Version string
Group string
Port int
Interval time.Duration
// Payload returns this node's status ("starting" / "running") and its
// resources. The envelope and NodeInfo are filled in by the Node.
Payload func() (status string, resources []Resource)
}
func (c *Config) applyDefaults() {
if c.Group == "" {
c.Group = DefaultGroup
}
if c.Port == 0 {
c.Port = DefaultPort
}
if c.Interval <= 0 {
c.Interval = DefaultInterval
}
}
type seen struct {
node NodeInfo
resources []Resource
addr string
lastSeen time.Time
}
// Node is one beacon v2 participant: it advertises its resources, answers
// probes, and keeps a TTL map of every other node it has heard from.
type Node struct {
cfg Config
group net.IP
pc *ipv4.PacketConn
closer interface{ Close() error }
// ifaces are the multicast-capable interfaces we joined and send on.
// A container attached to two docker networks (metashare-app on `pcs`
// and `metamesh-mesh`, metawatch-* on `metawatch` and `metamesh-mesh`)
// has two entries here, and MUST be advertised on both — sending via the
// default route alone silently covers only one network.
ifaces []net.Interface
// sendMu serialises SetMulticastInterface + WriteTo, which are a
// two-step operation on one shared socket.
sendMu sync.Mutex
mu sync.RWMutex
nodes map[string]*seen
stopChan chan struct{}
stopOnce sync.Once
wg sync.WaitGroup
}
// New creates a Node. It does not touch the network until Start.
func New(cfg Config) *Node {
cfg.applyDefaults()
return &Node{
cfg: cfg,
nodes: make(map[string]*seen),
stopChan: make(chan struct{}),
}
}
// Start opens the multicast socket, joins the group on every eligible
// interface, and begins advertising. A join failure on one interface is logged
// and skipped rather than fatal — a host that blocks multicast degrades
// instead of failing to boot.
func (n *Node) Start() error {
n.group = net.ParseIP(n.cfg.Group)
if n.group == nil || n.group.To4() == nil {
return fmt.Errorf("beacon: invalid multicast group %q", n.cfg.Group)
}
lc := net.ListenConfig{Control: reusePort}
conn, err := lc.ListenPacket(context.Background(), "udp4", fmt.Sprintf(":%d", n.cfg.Port))
if err != nil {
return fmt.Errorf("beacon: listen on :%d: %w", n.cfg.Port, err)
}
n.closer = conn
pc := ipv4.NewPacketConn(conn)
// TTL 1: link-local only. Discovery never routes off the local segment;
// the two-host topology keeps two separate meshes by design.
if err := pc.SetMulticastTTL(1); err != nil {
log.Printf("[beacon] Warning: could not set multicast TTL: %v", err)
}
n.pc = pc
n.ifaces = eligibleInterfaces()
if len(n.ifaces) == 0 {
log.Println("[beacon] Warning: no multicast-capable interfaces found; discovery will be inert")
}
group := &net.UDPAddr{IP: n.group}
joined := 0
for i := range n.ifaces {
if err := pc.JoinGroup(&n.ifaces[i], group); err != nil {
log.Printf("[beacon] Warning: join %s on %s failed: %v", n.cfg.Group, n.ifaces[i].Name, err)
continue
}
joined++
}
log.Printf("[beacon] Listening on %s:%d as %s/%s (joined %d of %d interfaces)",
n.cfg.Group, n.cfg.Port, n.cfg.Name, n.cfg.Instance, joined, len(n.ifaces))
n.wg.Add(2)
go n.readLoop()
go n.advertiseLoop()
return nil
}
// Stop multicasts a bye — so neighbours drop us immediately instead of
// waiting out the staleness window — then closes the socket and waits for the
// loops to exit.
func (n *Node) Stop() error {
var err error
n.stopOnce.Do(func() {
n.broadcast(Message{Proto: Proto, V: Version, Type: TypeBye, Node: n.nodeInfo("")})
close(n.stopChan)
if n.closer != nil {
err = n.closer.Close()
}
n.wg.Wait()
})
return err
}
// Probe multicasts a probe on every interface. Every node owning a resource
// matching one of want (every node, when want is empty) replies immediately,
// so a caller does not have to wait out an interval.
func (n *Node) Probe(want ...string) {
n.broadcast(Message{Proto: Proto, V: Version, Type: TypeProbe, From: n.cfg.Instance, Want: want})
}
// Neighbors returns every node heard from within the staleness window,
// sorted by (name, instance). Expired entries are dropped as a side effect —
// expiry is a read-time concern, so there is no reaper goroutine.
func (n *Node) Neighbors() []Neighbor {
cutoff := time.Now().Add(-n.cfg.Interval * LivenessFactor)
n.mu.Lock()
out := make([]Neighbor, 0, len(n.nodes))
for k, s := range n.nodes {
if s.lastSeen.Before(cutoff) {
delete(n.nodes, k)
continue
}
out = append(out, newNeighbor(s.node, s.resources, s.addr, s.lastSeen))
}
n.mu.Unlock()
sort.Slice(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].Instance < out[j].Instance
})
return out
}
// NeighborsByName collapses multiple instances of one name down to the most
// recently seen — the shape the nav menu wants.
func (n *Node) NeighborsByName() []Neighbor {
best := make(map[string]Neighbor)
for _, nb := range n.Neighbors() {
if cur, ok := best[nb.Name]; !ok || nb.LastSeen > cur.LastSeen {
best[nb.Name] = nb
}
}
out := make([]Neighbor, 0, len(best))
for _, nb := range best {
out = append(out, nb)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Self returns this node as a row, so callers can render themselves without
// waiting to hear their own multicast echo back.
func (n *Node) Self() Neighbor {
status, resources := n.payload()
return newNeighbor(*n.nodeInfo(status), resources, "", time.Now())
}
func (n *Node) payload() (string, []Resource) {
if n.cfg.Payload == nil {
return "running", nil
}
return n.cfg.Payload()
}
func (n *Node) nodeInfo(status string) *NodeInfo {
return &NodeInfo{Name: n.cfg.Name, Instance: n.cfg.Instance, Version: n.cfg.Version, Status: status}
}
func (n *Node) advertiseMsg() Message {
status, resources := n.payload()
return Message{Proto: Proto, V: Version, Type: TypeAdvertise, Node: n.nodeInfo(status), Resources: resources}
}
func (n *Node) readLoop() {
defer n.wg.Done()
buf := make([]byte, ReadBufferSize)
for {
select {
case <-n.stopChan:
return
default:
}
nRead, _, src, err := n.pc.ReadFrom(buf)
if err != nil {
select {
case <-n.stopChan:
return
default:
}
log.Printf("[beacon] Read error: %v", err)
continue
}
msg, ok := Parse(buf[:nRead])
if !ok {
continue // v1 or foreign traffic on the shared group
}
switch msg.Type {
case TypeProbe:
if msg.From == n.cfg.Instance {
continue
}
ad := n.advertiseMsg()
wanted := len(msg.Want) == 0
for _, r := range ad.Resources {
if r.MatchesAny(msg.Want) {
wanted = true
break
}
}
if wanted {
// Unicast straight back to the prober's source address; its
// ephemeral source port is where it is listening.
n.send(ad, src)
}
case TypeAdvertise, TypeBye:
// Drop our own multicast echo. Loopback stays enabled so a
// single-container test still sees traffic; we filter by identity.
if msg.Node.Instance == n.cfg.Instance {
continue
}
n.record(msg, src)
}
}
}
func (n *Node) record(msg Message, src net.Addr) {
addr := ""
if src != nil {
if udp, ok := src.(*net.UDPAddr); ok {
addr = udp.IP.String()
} else {
addr = src.String()
}
}
n.mu.Lock()
defer n.mu.Unlock()
if msg.Type == TypeBye {
delete(n.nodes, msg.Node.Instance)
return
}
n.nodes[msg.Node.Instance] = &seen{node: *msg.Node, resources: msg.Resources, addr: addr, lastSeen: time.Now()}
}
func (n *Node) advertiseLoop() {
defer n.wg.Done()
// Advertise immediately, then probe once so the map fills without
// waiting a full interval for everyone else's ticker.
n.broadcast(n.advertiseMsg())
n.Probe()
ticker := time.NewTicker(n.cfg.Interval)
defer ticker.Stop()
for {
select {
case <-n.stopChan:
return
case <-ticker.C:
n.broadcast(n.advertiseMsg())
}
}
}
func encode(msg Message) ([]byte, bool) {
body, err := json.Marshal(msg)
if err != nil {
return nil, false
}
if len(body) > MaxDatagram {
log.Printf("[beacon] Warning: %d-byte datagram exceeds the fragmentation-safe %d", len(body), MaxDatagram)
}
return body, true
}
// send unicasts msg to dst (a probe reply).
func (n *Node) send(msg Message, dst net.Addr) {
body, ok := encode(msg)
if !ok {
return
}
n.sendMu.Lock()
defer n.sendMu.Unlock()
if _, err := n.pc.WriteTo(body, nil, dst); err != nil {
log.Printf("[beacon] Reply to %s failed: %v", dst, err)
}
}
// broadcast writes one message to the group once per eligible interface.
// Writing once and letting the routing table choose would reach exactly one
// network, which is the failure mode this protocol exists to avoid.
func (n *Node) broadcast(msg Message) {
if n.pc == nil {
return
}
body, ok := encode(msg)
if !ok {
return
}
dst := &net.UDPAddr{IP: n.group, Port: n.cfg.Port}
n.sendMu.Lock()
defer n.sendMu.Unlock()
for i := range n.ifaces {
if err := n.pc.SetMulticastInterface(&n.ifaces[i]); err != nil {
log.Printf("[beacon] Warning: select interface %s failed: %v", n.ifaces[i].Name, err)
continue
}
if _, err := n.pc.WriteTo(body, nil, dst); err != nil {
log.Printf("[beacon] Warning: send on %s failed: %v", n.ifaces[i].Name, err)
}
}
}
// eligibleInterfaces returns every up, non-loopback, multicast-capable
// interface that has an IPv4 address.
func eligibleInterfaces() []net.Interface {
all, err := net.Interfaces()
if err != nil {
log.Printf("[beacon] Warning: could not enumerate interfaces: %v", err)
return nil
}
var out []net.Interface
for _, ifi := range all {
if ifi.Flags&net.FlagUp == 0 ||
ifi.Flags&net.FlagLoopback != 0 ||
ifi.Flags&net.FlagMulticast == 0 {
continue
}
addrs, err := ifi.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil {
out = append(out, ifi)
break
}
}
}
return out
}
// reusePort sets SO_REUSEADDR + SO_REUSEPORT so several listeners (a test, a
// sidecar, a beacon v1 responder in the same netns) can bind 9099 on one host.
func reusePort(_, _ string, c syscall.RawConn) error {
var serr error
err := c.Control(func(fd uintptr) {
serr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1)
if serr == nil {
serr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
}
})
if err != nil {
return err
}
return serr
}
// LocalIPv4 returns this host's first non-loopback IPv4 address, or the
// hostname when none can be determined. Same fallback the file-based registry
// used (discovery/service.go), kept identical so announced URLs don't shift.
func LocalIPv4() string {
addrs, err := net.InterfaceAddrs()
if err == nil {
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
return ipnet.IP.String()
}
}
}
}
host, _ := os.Hostname()
return host
}
+237
View File
@@ -0,0 +1,237 @@
// Package meshdisco implements beacon v2: local resource advertise / discover
// over UDP multicast (239.255.99.1:9099), the protocol every meta-* service
// and SDK-hosted plugin uses to find its neighbours.
//
// A node advertises Resources, each tagged with capability strings
// ("metamesh.core", "metamesh.service/meta-sort", "metamesh.transport/nzb@1");
// consumers keep a live view of everyone else's and filter by capability.
// meta-core advertises "metamesh.core" carrying its /urls block, which is how
// services locate it without a shared volume.
//
// The wire protocol is documented in docs/project-architecture/beacon-v2.md.
// Keep this package and that doc in step — the Rust (meta-feeder-sdk),
// TypeScript and Python implementations are written against the doc.
package meshdisco
import (
"encoding/json"
"strings"
"time"
)
const (
// Proto and Version form the envelope every v2 message carries. Anything
// else on the group (beacon v1, junk) is dropped silently.
Proto = "beacon"
Version = 2
// DefaultGroup / DefaultPort are beacon's endpoint, on purpose: one
// well-known place for every kind of local resource. v1 and v2 coexist
// because v2 never sends v1's "discovery" / "announce" types.
DefaultGroup = "239.255.99.1"
DefaultPort = 9099
// DefaultInterval is how often an unsolicited advertise is emitted.
DefaultInterval = 10 * time.Second
// LivenessFactor multiplies the interval to get the staleness window.
// Expiry is evaluated at read time; there is no reaper.
LivenessFactor = 3
// ReadBufferSize is the receive buffer; MaxDatagram is what a sender
// stays under so nothing fragments.
ReadBufferSize = 8192
MaxDatagram = 1400
TypeProbe = "probe"
TypeAdvertise = "advertise"
TypeBye = "bye"
// CapCore is meta-core's capability; its resource carries Data.urls.
CapCore = "metamesh.core"
// CapServicePrefix + name is every service's capability.
CapServicePrefix = "metamesh.service/"
CapAnyService = "metamesh.service/*"
)
// URLs mirrors meta-core's GET /urls response (api.URLsResponse) minus
// redisUrl. It travels as the metamesh.core resource's Data.urls, so a service
// locates meta-core from the advertise alone.
type URLs struct {
Hostname string `json:"hostname"`
BaseUrl string `json:"baseUrl"`
ApiUrl string `json:"apiUrl"`
WebdavUrl string `json:"webdavUrl"`
WebdavUrlInternal string `json:"webdavUrlInternal"`
}
// NodeInfo says who is advertising.
type NodeInfo struct {
Name string `json:"name"`
Instance string `json:"instance"`
Version string `json:"version,omitempty"`
// Status is "starting" | "running"; absent reads as running.
Status string `json:"status,omitempty"`
}
// Resource is one advertised thing.
type Resource struct {
ID string `json:"id"`
Caps []string `json:"caps"`
// Endpoints: well-known names http, ui, manifest, mcp. A value starting
// with "/" is relative to endpoints.http.
Endpoints map[string]string `json:"endpoints,omitempty"`
Rev string `json:"rev,omitempty"`
// Binds names the one consumer instance this resource belongs to.
Binds string `json:"binds,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
// Matches reports whether any cap matches pattern.
func (r Resource) Matches(pattern string) bool {
for _, c := range r.Caps {
if CapMatches(pattern, c) {
return true
}
}
return false
}
// MatchesAny reports whether any cap matches any pattern; an empty list matches.
func (r Resource) MatchesAny(patterns []string) bool {
if len(patterns) == 0 {
return true
}
for _, p := range patterns {
if r.Matches(p) {
return true
}
}
return false
}
// Endpoint returns the named endpoint as an absolute URL.
func (r Resource) Endpoint(name string) string {
v := r.Endpoints[name]
if strings.HasPrefix(v, "/") {
base := r.Endpoints["http"]
if base == "" {
return ""
}
return strings.TrimRight(base, "/") + v
}
return v
}
// Message is every v2 datagram.
type Message struct {
Proto string `json:"proto"`
V int `json:"v"`
Type string `json:"type"`
Node *NodeInfo `json:"node,omitempty"`
Resources []Resource `json:"resources,omitempty"`
// From and Want are probe-only.
From string `json:"from,omitempty"`
Want []string `json:"want,omitempty"`
}
// Parse decodes a datagram, returning ok=false for anything that is not a
// well-formed beacon v2 message (v1 traffic, other versions, unknown types,
// an advertise/bye without a node).
func Parse(b []byte) (Message, bool) {
var m Message
if err := json.Unmarshal(b, &m); err != nil {
return m, false
}
if m.Proto != Proto || m.V != Version {
return m, false
}
switch m.Type {
case TypeProbe:
return m, true
case TypeAdvertise, TypeBye:
if m.Node == nil || m.Node.Name == "" || m.Node.Instance == "" {
return m, false
}
return m, true
}
return m, false
}
func splitContract(s string) (string, string, bool) {
if i := strings.LastIndex(s, "@"); i >= 0 {
return s[:i], s[i+1:], true
}
return s, "", false
}
// CapMatches reports whether capability cap satisfies pattern:
// "*" matches everything; an "@N" on the pattern must equal the cap's
// contract (none on the pattern ignores it); "x/*" matches any variant of x;
// otherwise the bases must be equal.
func CapMatches(pattern, cap string) bool {
if pattern == "*" {
return true
}
pbase, pc, phas := splitContract(pattern)
cbase, cc, chas := splitContract(cap)
if phas && (!chas || pc != cc) {
return false
}
if prefix, ok := strings.CutSuffix(pbase, "/*"); ok {
rest, ok := strings.CutPrefix(cbase, prefix)
return ok && strings.HasPrefix(rest, "/") && len(rest) > 1
}
return pbase == cbase
}
// Neighbor is a node heard on the wire, as served by /api/neighbors.
type Neighbor struct {
NodeInfo
Resources []Resource `json:"resources"`
// Caps flattens every resource's caps, for consumers that just filter.
Caps []string `json:"caps"`
// BaseUrl is the metamesh.service/* resource's endpoints.ui — kept so
// v1-era readers (the nav menu) keep working.
BaseUrl string `json:"baseUrl,omitempty"`
// Addr is the packet's source address, never a payload claim.
Addr string `json:"addr"`
// LastSeen is unix seconds.
LastSeen float64 `json:"lastSeen"`
}
// Matches reports whether any resource matches pattern.
func (n Neighbor) Matches(pattern string) bool {
for _, r := range n.Resources {
if r.Matches(pattern) {
return true
}
}
return false
}
func newNeighbor(node NodeInfo, resources []Resource, addr string, seen time.Time) Neighbor {
nb := Neighbor{
NodeInfo: node,
Resources: resources,
Caps: []string{},
Addr: addr,
LastSeen: float64(seen.UnixNano()) / 1e9,
}
if nb.Resources == nil {
nb.Resources = []Resource{}
}
seenCap := map[string]bool{}
for _, r := range resources {
for _, c := range r.Caps {
if !seenCap[c] {
seenCap[c] = true
nb.Caps = append(nb.Caps, c)
}
}
if nb.BaseUrl == "" && r.Matches(CapAnyService) {
nb.BaseUrl = r.Endpoint("ui")
}
}
return nb
}
+140
View File
@@ -0,0 +1,140 @@
package meshdisco
import (
"encoding/json"
"net"
"testing"
"time"
)
func TestCapMatches(t *testing.T) {
cases := []struct {
pattern, cap string
want bool
}{
{"metamesh.transport/*", "metamesh.transport/nzb@1", true},
{"metamesh.transport/*", "metamesh.transport/ipfs", true},
{"metamesh.transport/*", "metamesh.transport", false},
{"metamesh.transport/*", "metamesh.transportx/a", false},
{"metamesh.transport/*", "metamesh.transport/", false},
{"metamesh.transport/nzb", "metamesh.transport/nzb@2", true},
{"metamesh.transport/nzb@2", "metamesh.transport/nzb@2", true},
{"metamesh.transport/nzb@2", "metamesh.transport/nzb@1", false},
{"metamesh.transport/nzb@2", "metamesh.transport/nzb", false},
{"metamesh.transport/*@1", "metamesh.transport/nzb@1", true},
{"metamesh.core", "metamesh.core", true},
{"metamesh.core", "metamesh.core2", false},
{"mcp", "mcp/x", false},
{"*", "anything@3", true},
}
for _, c := range cases {
if got := CapMatches(c.pattern, c.cap); got != c.want {
t.Errorf("CapMatches(%q, %q) = %v, want %v", c.pattern, c.cap, got, c.want)
}
}
}
func TestParseRejectsV1AndForeign(t *testing.T) {
for _, raw := range []string{
`{"type":"discovery"}`,
`{"type":"announce","name":"x","tools":[]}`,
`{"v":1,"type":"announce","name":"meta-sort"}`,
`{"proto":"beacon","v":3,"type":"probe"}`,
`{"proto":"beacon","v":2,"type":"announce"}`,
`{"proto":"beacon","v":2,"type":"advertise"}`,
"\x00garbage",
} {
if _, ok := Parse([]byte(raw)); ok {
t.Errorf("Parse(%q) accepted", raw)
}
}
if _, ok := Parse([]byte(`{"proto":"beacon","v":2,"type":"probe","want":["metamesh.core"]}`)); !ok {
t.Error("valid probe rejected")
}
}
func TestNeighborRowCarriesCapsAndBaseUrl(t *testing.T) {
nb := newNeighbor(
NodeInfo{Name: "meta-sort", Instance: "metasort-app"},
[]Resource{{ID: "service", Caps: []string{"metamesh.service/meta-sort"}, Endpoints: map[string]string{"ui": "https://x"}}},
"10.0.0.1", time.Now(),
)
if nb.BaseUrl != "https://x" || len(nb.Caps) != 1 || !nb.Matches(CapAnyService) {
t.Fatalf("unexpected row: %+v", nb)
}
}
// End to end over loopback: a probe with a non-matching want gets no reply;
// a matching one gets the advertise; advertise then bye updates the view.
func TestProbeAndRecord(t *testing.T) {
port := freePort(t)
n := New(Config{
Name: "meta-core", Instance: "core-1", Port: port, Interval: time.Hour,
Payload: func() (string, []Resource) {
return "running", []Resource{{ID: "core", Caps: []string{CapCore}}}
},
})
if err := n.Start(); err != nil {
t.Fatal(err)
}
defer n.Stop()
c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
defer c.Close()
dst := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}
buf := make([]byte, ReadBufferSize)
send := func(m Message) {
b, _ := json.Marshal(m)
if _, err := c.WriteToUDP(b, dst); err != nil {
t.Fatal(err)
}
}
send(Message{Proto: Proto, V: Version, Type: TypeProbe, From: "cli", Want: []string{"metamesh.service/*"}})
c.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
if _, _, err := c.ReadFromUDP(buf); err == nil {
t.Fatal("non-matching probe got a reply")
}
send(Message{Proto: Proto, V: Version, Type: TypeProbe, From: "cli", Want: []string{CapCore}})
c.SetReadDeadline(time.Now().Add(2 * time.Second))
nr, _, err := c.ReadFromUDP(buf)
if err != nil {
t.Fatalf("matching probe got no reply: %v", err)
}
if m, ok := Parse(buf[:nr]); !ok || m.Type != TypeAdvertise || m.Node.Instance != "core-1" {
t.Fatalf("bad reply: %s", buf[:nr])
}
peer := &NodeInfo{Name: "meta-sort", Instance: "sort-1"}
send(Message{Proto: Proto, V: Version, Type: TypeAdvertise, Node: peer,
Resources: []Resource{{ID: "service", Caps: []string{"metamesh.service/meta-sort"}}}})
waitFor(t, func() bool { return len(n.Neighbors()) == 1 })
send(Message{Proto: Proto, V: Version, Type: TypeBye, Node: peer})
waitFor(t, func() bool { return len(n.Neighbors()) == 0 })
}
func freePort(t *testing.T) int {
c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
defer c.Close()
return c.LocalAddr().(*net.UDPAddr).Port
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("condition not reached")
}
+1 -1
View File
@@ -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) {
+75 -233
View File
@@ -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 continue
} }
if time.Since(stableSince) >= 300*time.Millisecond { n, err := strconv.Atoi(row.V)
return if err != nil || n <= 0 {
continue
} }
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)
func (i *Indexer) consumeLoop() {
defer i.wg.Done()
lastID := "0"
for {
select {
case <-i.ctx.Done():
return
default:
} }
return nil
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 != nil {
if err == redis.Nil || i.ctx.Err() != nil { return nil, err
continue
} }
log.Printf("[SchemaIndexer] XRead error: %v", err) snap := &SchemaResponse{
time.Sleep(1 * time.Second) Fields: make(map[string]*FieldSchema, len(fields)),
continue GeneratedAt: time.Now().UTC(),
Source: "live",
}
for fp, fs := range fields {
snap.Fields[fp] = computeFieldSchema(fs)
}
return snap, nil
} }
for _, stream := range res { type labelPair struct {
for _, msg := range stream.Messages { prim Primitive
lastID = msg.ID hint Hint
i.processEvent(msg.Values)
atomic.AddUint64(&i.processed, 1)
}
}
}
}
func (i *Indexer) processEvent(values map[string]any) {
op, _ := values["type"].(string)
key, _ := values["key"].(string)
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 -1
View File
@@ -24,7 +24,7 @@ type ExportOptions struct {
SourceHost string SourceHost string
} }
// Exporter writes snapshot ZIPs from a live Redis store. // Exporter writes snapshot ZIPs from the live metadata store.
type Exporter struct { type Exporter struct {
storage *storage.Client storage *storage.Client
} }
+3 -3
View File
@@ -8,7 +8,7 @@ import (
"strings" "strings"
) )
// Flat keys use "/" as the separator. Property paths inside Redis are // Flat keys use "/" as the separator. Property paths inside the store are
// stored as e.g. "fileinfo/streamdetails/video/0/codec". // stored as e.g. "fileinfo/streamdetails/video/0/codec".
const flatSep = "/" const flatSep = "/"
@@ -33,7 +33,7 @@ func Reconstruct(flat map[string]string) interface{} {
} }
// Flatten is the inverse of Reconstruct: walks a nested value and produces a // Flatten is the inverse of Reconstruct: walks a nested value and produces a
// flat property map whose values are all strings (Redis-ready). // flat property map whose values are all strings (store-ready).
// //
// Arrays become numeric path segments ("genre/0", "genre/1"). Nil/missing // Arrays become numeric path segments ("genre/0", "genre/1"). Nil/missing
// values are stored as "" to preserve the field's presence — this matches // values are stored as "" to preserve the field's presence — this matches
@@ -127,7 +127,7 @@ func flattenInto(v interface{}, path string, out map[string]string) {
out[path] = "" out[path] = ""
case map[string]interface{}: case map[string]interface{}:
if len(t) == 0 && path != "" { if len(t) == 0 && path != "" {
// Empty objects don't have a Redis representation; skip. // Empty objects don't have a flat-key representation; skip.
return return
} }
for k, child := range t { for k, child := range t {
+2 -2
View File
@@ -73,7 +73,7 @@ type ImportResult struct {
PluginOutput *PluginOutputResult `json:"pluginOutput,omitempty"` PluginOutput *PluginOutputResult `json:"pluginOutput,omitempty"`
} }
// Importer applies snapshot ZIPs to a live Redis store. // Importer applies snapshot ZIPs to the live metadata store.
type Importer struct { type Importer struct {
storage *storage.Client storage *storage.Client
} }
@@ -83,7 +83,7 @@ func NewImporter(stor *storage.Client) *Importer {
} }
// Import reads a ZIP from r (which must support io.ReaderAt; callers pass // Import reads a ZIP from r (which must support io.ReaderAt; callers pass
// *bytes.Reader after buffering the upload) and applies it to Redis. // *bytes.Reader after buffering the upload) and applies it to the store.
func (i *Importer) Import(r io.ReaderAt, size int64, opts ImportOptions) (*ImportResult, error) { func (i *Importer) Import(r io.ReaderAt, size int64, opts ImportOptions) (*ImportResult, error) {
if i.storage == nil || !i.storage.IsConnected() { if i.storage == nil || !i.storage.IsConnected() {
return nil, fmt.Errorf("storage not connected") return nil, fmt.Errorf("storage not connected")
+1 -1
View File
@@ -16,7 +16,7 @@ type WipeResult struct {
MetadataDeleted int64 `json:"metadataDeleted"` MetadataDeleted int64 `json:"metadataDeleted"`
} }
// Wiper clears metadata from Redis. // Wiper clears metadata from the store.
type Wiper struct { type Wiper struct {
storage *storage.Client storage *storage.Client
} }
+190 -215
View File
@@ -5,10 +5,9 @@ import (
"fmt" "fmt"
"strconv" "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 // This file implements the CID-resolution layer that lives on top of the
@@ -18,7 +17,7 @@ import (
// The UUID is an internal implementation detail; the mental model is // The UUID is an internal implementation detail; the mental model is
// "N CIDs → one record of keys→metadata". // "N CIDs → one record of keys→metadata".
// - Every known CID for a file is registered as a reverse-index entry: // - Every known CID for a file is registered as a reverse-index entry:
// cid:<bareCid> → <uuid> (Redis STRING) // cid:<bareCid> → <uuid>
// and as a member of the per-root bare-CID key-set: // and as a member of the per-root bare-CID key-set:
// file:<uuid>/cids/<bareCid> = "true" (flat key, sentinel value) // file:<uuid>/cids/<bareCid> = "true" (flat key, sentinel value)
// - A CID is self-describing: its algorithm is recoverable from the CIDv1 // - A CID is self-describing: its algorithm is recoverable from the CIDv1
@@ -27,14 +26,13 @@ import (
// *derived by rank* from the key-set on read (GetMetadataDocument), never // *derived by rank* from the key-set on read (GetMetadataDocument), never
// stored. // stored.
// //
// Auto-alias hooks in SetProperty/SetMetadataFlat/MergeMetadataFlat catch // The record write path (writeFieldsTx) catches any write of a cids/<cid>
// any write of a cids/<cid> field and register the reverse-index entry, so // field and registers the reverse-index entry in the same transaction, so
// writers that just emit the key-set members don't need to know about the // writers that just emit the key-set members don't need to know about the
// reverse index at all. The hook helpers are *Locked — they assume c.mu is // reverse index at all. The helpers here take the caller's *kv.Tx: a write
// already held by the caller, avoiding nested RLock (which can deadlock // must never open a second transaction (there is one writer connection).
// 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,117 +43,113 @@ 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 // Duplicate paths — other physical locations whose content hashes to the same
// whose content hashes to the same CIDs as this root. The watcher writes // CIDs as a root — live as dup:<rid>/<path> = "" (a key-set). The watcher
// here when it sees a new path for content that's already indexed; meta-dup // writes one when it sees a new path for content that's already indexed.
// 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 // 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 // in the root 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 // 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. // reachable only via the index, which works but defeats the point.
func (c *Client) Mint(filePath string, size, mtimeNano int64) (string, error) { func (c *Client) Mint(filePath string, size, mtimeNano int64) (string, error) {
c.mu.RLock() var uuid string
defer c.mu.RUnlock() err := c.update(func(tx *kv.Tx) error {
var err error
if c.client == nil { uuid, err = mintTx(tx, filePath, size, mtimeNano)
return "", fmt.Errorf("not connected") return err
})
return uuid, err
} }
func mintTx(tx *kv.Tx, filePath string, size, mtimeNano int64) (string, error) {
uuid, err := NewUUID() uuid, err := NewUUID()
if err != nil { if err != nil {
return "", fmt.Errorf("uuid: %w", err) return "", fmt.Errorf("uuid: %w", err)
} }
return uuid, writeFieldsTx(tx, uuid, map[string]string{
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) "filePath": filePath,
defer cancel() "sizeByte": strconv.FormatInt(size, 10),
"mtimeNano": strconv.FormatInt(mtimeNano, 10),
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 // 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". // index cid:<cidStr> → uuid and the key-set member cids/<cidStr> = "true".
// Idempotent: registering the same CID twice is a no-op. // Idempotent: registering the same CID twice is a no-op.
func (c *Client) AddAlias(uuid, cidStr string) error { 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 == "" { if uuid == "" || cidStr == "" {
return fmt.Errorf("uuid and cid are required") return fmt.Errorf("uuid and cid are required")
} }
return c.update(func(tx *kv.Tx) error {
return writeFieldsTx(tx, uuid, map[string]string{CIDsKeyPrefix + cidStr: "true"})
})
}
keysetField := CIDsKeyPrefix + cidStr // addAliasTx registers the reverse-index entry for a cids/<cid> member the
keysetKey := c.buildKeyPrefix(uuid) + keysetField // caller is writing (writeFieldsTx writes the member itself).
func addAliasTx(tx *kv.Tx, uuid, cidStr string) error {
// Every branch below writes the key-set member, so index the field name if uuid == "" || cidStr == "" {
// once here — including the two guard branches that skip the alias itself. return fmt.Errorf("uuid and cid are required")
// 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 // 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 // cid:<cid> → <cid> (i.e. the root IS the cid value), that's the
// dual-root failure mode in disguise — it means somebody wrote // dual-root failure mode in disguise — it means somebody wrote
// /meta/<cid> without first resolving via ResolveRoot. Refuse the alias // /meta/<cid> without first resolving via ResolveRoot. Refuse the alias
// write; leave any existing alias alone. Still record the key-set // write; leave any existing alias alone. The key-set membership is still
// membership so the field-level write succeeds. // recorded by the caller, so the field-level write succeeds.
if uuid == cidStr { if uuid == cidStr {
return c.client.Set(ctx, keysetKey, "true", 0).Err() return nil
} }
// Guard against the dual-root pattern: if cid:<cid> already points at a // Guard against the dual-root pattern: if cid:<cid> already points at a
// DIFFERENT uuid, don't silently overwrite. Leave the existing alias and // DIFFERENT uuid, don't silently overwrite. Leave the existing alias; the
// just record the key-set membership so it's still discoverable via the // key-set membership keeps the CID discoverable via root → cids.
// root → cids enumeration. indexKey := CIDIndexPrefix + cidStr
indexKey := c.buildCIDIndexKey(cidStr) existing, _, err := tx.Get(tx.Context(), indexKey)
existing, err := c.client.Get(ctx, indexKey).Result() if err != nil {
if err != nil && err != redis.Nil {
return fmt.Errorf("read existing alias: %w", err) return fmt.Errorf("read existing alias: %w", err)
} }
if existing != "" && existing != uuid { if existing != "" && existing != uuid {
return c.client.Set(ctx, keysetKey, "true", 0).Err() return nil
}
if existing == uuid {
return nil
}
return tx.Put(indexKey, uuid)
} }
pipe := c.client.Pipeline() // removeAliasTx is the inverse of addAliasTx: it drops the reverse-index
pipe.Set(ctx, indexKey, uuid, 0) // entry cid:<cid> → <uuid>. The caller deletes the cids/<cid> key-set member
pipe.Set(ctx, keysetKey, "true", 0) // itself (DeleteProperty does both).
if _, err := pipe.Exec(ctx); err != nil { //
return fmt.Errorf("add alias pipeline: %w", err) // ⚠ Why this exists. A cids/ member write touches two keys — the flat
} // file:<uuid>/cids/<cid> and this reverse alias — while the delete path once
// only removed the first. 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 addAliasTx, which leaves the first writer's alias in
// place); deleting a member from the loser must not unpick the winner's index.
func removeAliasTx(tx *kv.Tx, uuid, cidStr string) error {
if uuid == "" || cidStr == "" {
return nil return nil
} }
indexKey := CIDIndexPrefix + cidStr
existing, ok, err := tx.Get(tx.Context(), indexKey)
if err != nil {
return fmt.Errorf("read existing alias: %w", err)
}
if !ok || existing != uuid {
// Absent, or somebody else owns this alias. Leave it.
return nil
}
_, err = tx.Delete(indexKey)
return 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.
@@ -174,8 +168,8 @@ func (c *Client) addAliasLocked(ctx context.Context, uuid, cidStr string) error
// - 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 == "" {
@@ -192,23 +186,16 @@ 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)
} }
@@ -216,127 +203,49 @@ func (c *Client) GetByCID(cidStr string) (string, error) {
} }
// AddDuplicatePath records that path is another physical location whose // AddDuplicatePath records that path is another physical location whose
// content matches uuid. Returns true if path was newly added (not already // content matches uuid. Returns true if path was newly added. The watcher
// in the set). The watcher calls this when it sees a file whose midhash // calls this when it sees a file whose midhash matches an existing root but
// matches an existing root but at a different filePath. // at a different filePath.
func (c *Client) AddDuplicatePath(uuid, path string) (bool, error) { func (c *Client) AddDuplicatePath(uuid, path string) (bool, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.client == nil {
return false, fmt.Errorf("not connected")
}
if uuid == "" || path == "" { if uuid == "" || path == "" {
return false, nil return false, nil
} }
added := false
err := c.update(func(tx *kv.Tx) error {
var err error
added, err = tx.PutIfAbsent(dupPrefix(uuid)+path, "")
return err
})
return added, err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // cidsForRoot returns a root's bare-CID key-set members (cids/ prefix
defer cancel() // stripped).
func cidsForRoot(ctx context.Context, r kv.Reader, uuid string) ([]string, error) {
added, err := c.client.SAdd(ctx, c.buildKeyPrefix(uuid)+DuplicatesField, path).Result() prefix := recordPrefix(uuid) + CIDsKeyPrefix
rows, err := r.Range(ctx, prefix, "", 0)
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):])
} }
return out, nil
// 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 // DeleteRoot removes everything keyed off uuid: every file:<uuid>/* property
// (including the cids/<cid> key-set), every reverse-index entry for those // (including the cids/<cid> key-set), every reverse-index entry for those
// CIDs, and the index membership. Idempotent — a uuid with no remaining keys // CIDs, its duplicates, search row and index membership. Idempotent — a uuid
// is a successful no-op. // with no remaining keys is a successful no-op.
func (c *Client) DeleteRoot(uuid string) error { 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 == "" { if uuid == "" {
return nil return nil
} }
return c.update(func(tx *kv.Tx) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) _, err := deleteRecordTx(tx, uuid)
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 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)
}
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])
}
} }
// cidFromKeysetField returns the bare CID encoded in a cids/<cid> field name, // cidFromKeysetField returns the bare CID encoded in a cids/<cid> field name,
@@ -366,24 +275,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
@@ -403,9 +311,13 @@ func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
} }
} }
dups, err := c.client.SMembers(ctx, prefix+DuplicatesField).Result() dupRows, err := s.Range(ctx, dupPrefix(uuid), "", 0)
if err != nil && err != redis.Nil { if err != nil {
return nil, fmt.Errorf("smembers duplicates: %w", err) return nil, fmt.Errorf("read duplicates: %w", err)
}
dups := make([]string, 0, len(dupRows))
for _, row := range dupRows {
dups = append(dups, row.K[len(dupPrefix(uuid)):])
} }
if len(flat) == 0 && len(cids) == 0 && len(dups) == 0 { if len(flat) == 0 && len(cids) == 0 && len(dups) == 0 {
@@ -418,3 +330,66 @@ 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")
}
var (
uuid string
minted bool
)
// One transaction: the lookup and the mint cannot interleave with another
// registration of the same content, so two concurrent scans of one file
// can no longer mint two roots for it.
err := c.update(func(tx *kv.Tx) error {
ctx := tx.Context()
existing, _, err := tx.Get(ctx, CIDIndexPrefix+midhash)
if err != nil {
return fmt.Errorf("GetByCID(%s): %w", midhash, err)
}
uuid = existing
if uuid == "" {
if uuid, err = mintTx(tx, absPath, size, mtimeNano); err != nil {
return fmt.Errorf("Mint(%s): %w", absPath, err)
}
minted = true
if err := writeFieldsTx(tx, uuid, map[string]string{CIDsKeyPrefix + midhash: "true"}); err != nil {
return fmt.Errorf("cids/%s for %s: %w", midhash, uuid, err)
}
return nil
}
current, _, err := tx.Get(ctx, recordPrefix(uuid)+"filePath")
if err != nil {
return err
}
if current != absPath {
if _, err := tx.PutIfAbsent(dupPrefix(uuid)+absPath, ""); err != nil {
return fmt.Errorf("AddDuplicatePath %s @ %s: %w", uuid, absPath, err)
}
return nil
}
if err := writeFieldsTx(tx, uuid, map[string]string{
"sizeByte": strconv.FormatInt(size, 10),
"mtimeNano": strconv.FormatInt(mtimeNano, 10),
}); err != nil {
return fmt.Errorf("refresh tuple for %s: %w", uuid, err)
}
return nil
})
if err != nil {
return "", false, err
}
return uuid, minted, nil
}
+159
View File
@@ -0,0 +1,159 @@
package storage
import (
"context"
"testing"
)
// Tests for retracting a `cids/<cid>` key-set member (cid_resolution.go's
// removeAliasTx, reached through DeleteProperty).
//
// Why this is worth its own file. Adding a member writes two keys — the flat
// `file:<uuid>/cids/<cid>` and the reverse alias `cid:<cid>` → `<uuid>`. Until
// removeAliasLocked (now removeAliasTx) existed, deleting removed only the
// first, 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.Store().Get(context.Background(), CIDIndexPrefix+cidStr)
if err != nil {
t.Fatalf("get alias: %v", err)
}
return got
}
func hasField(t *testing.T, c *Client, hashID, field string) bool {
t.Helper()
fields, err := c.GetMetadataFlat(hashID)
if err != nil {
t.Fatalf("read record: %v", err)
}
_, ok := fields[field]
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) {
withClient(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, ok, _ := c.Store().Get(context.Background(),
recordPrefix(uuid)+"cids/"+poisonedRoot); ok {
t.Fatalf("flat key should be gone, still %q", v)
}
// 2. the record read
if hasField(t, c, uuid, "cids/"+poisonedRoot) {
t.Fatal("record read 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) {
withClient(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) {
withClient(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)
}
})
}
+68 -54
View File
@@ -1,67 +1,74 @@
package storage package storage
import ( import (
"context"
"path/filepath"
"strings"
"testing" "testing"
"github.com/alicebob/miniredis/v2" "github.com/metazla/meta-core/internal/kv"
) )
// newTestClient spins an in-memory Redis and returns a connected Client with // newTestClient opens a fresh database in the test's temp dir.
// the given namespace prefix. The server is torn down with the test. func newTestClient(t *testing.T) *Client {
func newTestClient(t *testing.T, prefix string) (*Client, *miniredis.Miniredis) {
t.Helper() t.Helper()
c := NewClient()
s := miniredis.RunT(t) if err := c.Connect(filepath.Join(t.TempDir(), "meta-core.db")); err != nil {
c := NewClient(prefix) t.Fatalf("connect: %v", err)
if err := c.Connect("redis://" + s.Addr()); err != nil {
t.Fatalf("connect to miniredis: %v", err)
} }
t.Cleanup(func() { _ = c.Close() }) t.Cleanup(func() { _ = c.Close() })
return c, s return c
}
// allKeys lists every row (test helper; 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
} }
// TestClearAllMetadata_PurgesCIDReverseIndex is the regression test for the // TestClearAllMetadata_PurgesCIDReverseIndex is the regression test for the
// leak: ClearAllMetadata used to delete every file:<root>/* key and the // leak: ClearAllMetadata used to delete every record key and the root index,
// file:__index__ set, but left the cid:<cid> → root reverse-index entries // but left the cid:<cid> → root reverse-index entries behind. Those stale
// behind. Those stale aliases are not cosmetic — ResolveRoot consults them, so // aliases are not cosmetic — ResolveRoot consults them, so a surviving entry
// a surviving entry silently redirects a later write into a deleted root. // silently redirects a later write into a deleted root.
func TestClearAllMetadata_PurgesCIDReverseIndex(t *testing.T) { func TestClearAllMetadata_PurgesCIDReverseIndex(t *testing.T) {
for _, prefix := range []string{"", "mm:"} { c := newTestClient(t)
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 // Two records, each with several CID aliases (a midhash plus siblings) —
// siblings) — exactly what the watcher produces. // exactly what the watcher produces.
aliases := map[string][]string{} aliases := map[string][]string{}
for i, f := range []string{"watch/a.mkv", "watch/b.mkv"} { for i, f := range []string{"watch/a.mkv", "watch/b.mkv"} {
uuid, err := c.Mint(f, int64(100+i), int64(1000+i)) uuid, err := c.Mint(f, int64(100+i), int64(1000+i))
if err != nil { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)
} }
cids := []string{ cids := []string{"bafkmid" + uuid, "bafkfull" + uuid}
"bafkmid" + uuid,
"bafkfull" + uuid,
}
for _, cidStr := range cids { for _, cidStr := range cids {
if err := c.AddAlias(uuid, cidStr); err != nil { if err := c.AddAlias(uuid, cidStr); err != nil {
t.Fatalf("add alias %s: %v", cidStr, err) t.Fatalf("add alias %s: %v", cidStr, err)
} }
} }
if _, err := c.AddDuplicatePath(uuid, "watch/copy-"+f); err != nil {
t.Fatalf("add duplicate: %v", err)
}
aliases[uuid] = cids aliases[uuid] = cids
} }
// An entry already orphaned by a previous (buggy) clear: a // An entry already orphaned by a previous (buggy) clear: a reverse-index
// reverse-index key whose root no longer exists. The sweep is // key whose root no longer exists. The sweep is expected to collect it too.
// expected to collect it too. if err := c.SetRawValue(CIDIndexPrefix+"bafkorphan", "01OLDDEADROOT"); err != nil {
if err := s.Set(c.buildCIDIndexKey("bafkorphan"), "01OLDDEADROOT"); err != nil {
t.Fatalf("seed orphan: %v", err) t.Fatalf("seed orphan: %v", err)
} }
// Sanity: the aliases resolve before the clear.
for uuid, cids := range aliases { for uuid, cids := range aliases {
for _, cidStr := range cids { for _, cidStr := range cids {
if got := c.ResolveRoot(cidStr); got != uuid { if got := c.ResolveRoot(cidStr); got != uuid {
@@ -74,19 +81,25 @@ func TestClearAllMetadata_PurgesCIDReverseIndex(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ClearAllMetadata: %v", err) t.Fatalf("ClearAllMetadata: %v", err)
} }
// deletedCount counts files, not keys.
if deleted != 2 { if deleted != 2 {
t.Errorf("deletedCount = %d, want 2 (files, not keys)", deleted) t.Errorf("deletedCount = %d, want 2 (files, not keys)", deleted)
} }
// No reverse-index key may survive — not for the cleared records, // Nothing record-shaped may survive: not the records, not their aliases,
// not the pre-existing orphan. // duplicates or search rows, not the pre-existing orphan. (The event
if leaked := s.Keys(); len(leaked) != 0 { // streams and the schema sentinel are not metadata and stay.)
t.Errorf("keys survived the clear: %v", leaked) for _, k := range allKeys(t, c) {
for _, ns := range []string{recordNS, CIDIndexPrefix, rootsNS, ftsNS, dupNS} {
if strings.HasPrefix(k, ns) {
t.Errorf("key survived the clear: %q", k)
}
}
}
var ftsRows int
if err := c.Store().DB().QueryRow(`SELECT count(*) FROM fts`).Scan(&ftsRows); err != nil || ftsRows != 0 {
t.Errorf("fts rows after clear = %d (err %v), want 0", ftsRows, err)
} }
// And the aliases must no longer resolve: ResolveRoot returns the
// hash unchanged when there is no index entry.
for _, cids := range aliases { for _, cids := range aliases {
for _, cidStr := range cids { for _, cidStr := range cids {
if got := c.ResolveRoot(cidStr); got != cidStr { if got := c.ResolveRoot(cidStr); got != cidStr {
@@ -97,15 +110,13 @@ func TestClearAllMetadata_PurgesCIDReverseIndex(t *testing.T) {
if got, err := c.GetByCID("bafkorphan"); err != nil || got != "" { if got, err := c.GetByCID("bafkorphan"); err != nil || got != "" {
t.Errorf("post-clear GetByCID(bafkorphan) = %q (err %v), want \"\"", got, err) t.Errorf("post-clear GetByCID(bafkorphan) = %q (err %v), want \"\"", got, err)
} }
})
}
} }
// TestClearAllMetadata_LeavesForeignKeysAlone guards the sweep's blast radius: // TestClearAllMetadata_LeavesForeignKeysAlone guards the sweep's blast radius:
// it must key off buildCIDIndexKey (namespace-aware), not a hardcoded "cid:", // keys outside the metadata namespaces (magnet cache, coverage markers, a key
// so a clear on a prefixed client cannot reach another namespace's index. // that merely CONTAINS "cid:") must survive a metadata wipe.
func TestClearAllMetadata_LeavesForeignKeysAlone(t *testing.T) { func TestClearAllMetadata_LeavesForeignKeysAlone(t *testing.T) {
c, s := newTestClient(t, "mm:") c := newTestClient(t)
uuid, err := c.Mint("watch/a.mkv", 100, 1000) uuid, err := c.Mint("watch/a.mkv", 100, 1000)
if err != nil { if err != nil {
@@ -114,25 +125,28 @@ func TestClearAllMetadata_LeavesForeignKeysAlone(t *testing.T) {
if err := c.AddAlias(uuid, "bafkmine"); err != nil { if err := c.AddAlias(uuid, "bafkmine"); err != nil {
t.Fatalf("add alias: %v", err) t.Fatalf("add alias: %v", err)
} }
for k, v := range map[string]string{"other:cid:bafktheirs": "01THEIRROOT", "btmagnet:abc": "magnet:?xt=urn:btih:abc", "unrelated": "keep-me"} {
// Another namespace's reverse-index entry, plus an unrelated key. if err := c.SetRawValue(k, v); err != nil {
if err := s.Set("other:cid:bafktheirs", "01THEIRROOT"); err != nil { t.Fatalf("seed %s: %v", k, err)
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 { if _, err := c.ClearAllMetadata(); err != nil {
t.Fatalf("ClearAllMetadata: %v", err) t.Fatalf("ClearAllMetadata: %v", err)
} }
for _, k := range []string{"other:cid:bafktheirs", "unrelated"} { for _, k := range []string{"other:cid:bafktheirs", "btmagnet:abc", "unrelated"} {
if !s.Exists(k) { if _, _, ok, _ := c.GetKeyInfo(k); !ok {
t.Errorf("clear deleted foreign key %q — the cid sweep is not namespace-aware", k) t.Errorf("clear deleted foreign key %q", k)
} }
} }
if s.Exists(c.buildCIDIndexKey("bafkmine")) { if _, _, ok, _ := c.GetKeyInfo(CIDIndexPrefix + "bafkmine"); ok {
t.Errorf("own reverse-index entry survived the clear") t.Errorf("own reverse-index entry survived the clear")
} }
} }
// withClient runs fn against a fresh client.
func withClient(t *testing.T, fn func(t *testing.T, c *Client)) {
t.Helper()
fn(t, newTestClient(t))
}
+711 -1095
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
package storage
import (
"context"
"encoding/json"
"testing"
"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` are maintained by the
// write path. Whatever sequence of writes, modifications and deletions runs,
// they must equal what a full rebuild from the records computes.
func TestCountersStayExactAcrossWrites(t *testing.T) {
c := newTestClient(t)
seed(t, c, map[string]map[string]string{
"R1": {"title": "One", "sizeByte": "100", "title/eng": "One"},
"R2": {"title": "Two", "sizeByte": "x", "genres/Action": "true"},
"R3": {"title": "Three", "sizeByte": "300"},
})
if err := c.SetProperty("R2", "sizeByte", "200"); err != nil { // label string → int
t.Fatal(err)
}
if err := c.SetProperty("R1", "title", "One"); err != nil { // unchanged: no delta
t.Fatal(err)
}
if err := c.DeleteProperty("R1", "title/eng"); err != nil {
t.Fatal(err)
}
if _, err := c.DeleteMetadata("R3"); err != nil {
t.Fatal(err)
}
if n, err := c.CountRoots(); err != nil || n != 2 {
t.Fatalf("CountRoots = %d (err %v), want 2", 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"] != 2 {
t.Fatalf("sizeByte = %+v, want 2 ints", f)
}
if _, ok := snap.Fields["title"]; !ok {
t.Fatalf("title missing: %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 != 2 {
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")
}
}
// A re-persisted record with nothing new writes nothing and publishes nothing.
func TestUnchangedRecordWriteIsANoOp(t *testing.T) {
c := newTestClient(t)
rec := map[string]string{"title": "Naruto", "fileType": "video", "cids/bafkreiabc": "true"}
seed(t, c, map[string]map[string]string{"R1": rec})
before, _ := c.GetStreamLength(MetaEventsStream)
if err := c.SetMetadataFlat("R1", rec); err != nil {
t.Fatal(err)
}
if after, _ := c.GetStreamLength(MetaEventsStream); after != before {
t.Fatalf("re-persisting an unchanged record published %d events", after-before)
}
}
-41
View File
@@ -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
}
-486
View File
@@ -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
}
-464
View File
@@ -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": "ナルト",
"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": "ナルト", "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")
}
})
}
+32 -45
View File
@@ -1,83 +1,70 @@
package storage package storage
import ( import (
"context"
"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 key-layout version this build of meta-core expects.
// Distinct from the snapshot manifest version (which is on-disk format); // Distinct from the snapshot manifest version (which is on-disk format);
// this number describes the in-memory Redis schema — primarily, "are roots // this number describes the record layout — primarily, "are roots UUIDs or
// UUIDs or content-hash tokens, and do reverse-index keys cid:* exist?" // content-hash tokens, and do reverse-index keys cid:* exist?"
// //
// Bump only when a Redis-layout change can't be detected from the data // The Redis → SQLite move did not change the record layout (file:<rid>/<field>,
// itself or healed by the auto-alias hooks. Each bump is a flag day in // cid:<cid>), so it did not bump this: the one-time migration copied records
// alpha: operators wipe and restart. // verbatim.
//
// Bump only when a layout change can't be detected from the data itself or
// healed by the write path. Each bump is a flag day in alpha: operators wipe
// and restart.
const SchemaVersion = 2 const SchemaVersion = 2
// SchemaVersionKey is the sentinel under which we record SchemaVersion in // SchemaVersionKey is the sentinel under which we record SchemaVersion.
// Redis. Checked on every meta-core boot. // Checked on every meta-core boot.
const SchemaVersionKey = "meta-core:schema-version" const SchemaVersionKey = "meta-core:schema-version"
// EnsureSchemaVersion verifies the Redis store matches the expected schema // EnsureSchemaVersion verifies the store matches the expected schema version.
// version. Called once at startup, before any other reads/writes. // Called once at startup, before any other reads/writes.
// //
// Behavior: // Behavior:
// - sentinel present and matches expected → OK // - sentinel present and matches expected → OK
// - sentinel missing AND file:__index__ empty → fresh store, stamp sentinel // - sentinel missing AND no roots → fresh store, stamp sentinel
// - sentinel missing AND file:__index__ non-empty → legacy data, refuse // - sentinel missing AND roots exist → legacy data, refuse
// - sentinel present but mismatched → refuse, point operator at clean:all // - sentinel present but mismatched → refuse, point operator at clean:all
// //
// Refusal is hard-fail; the caller is expected to log.Fatalf and exit. // Refusal is hard-fail; the caller is expected to log.Fatalf and exit.
// The error message names the wipe remediation because in alpha there // The error message names the wipe remediation because in alpha there
// is no automated migration — operators bring up a clean store. // 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() return c.update(func(tx *kv.Tx) error {
defer c.mu.Unlock() ctx := tx.Context()
have, ok, err := tx.Get(ctx, SchemaVersionKey)
if c.client == nil { if err != nil {
return fmt.Errorf("not connected")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
key := c.buildKey(SchemaVersionKey)
have, err := c.client.Get(ctx, key).Result()
if err != nil && err != redis.Nil {
return fmt.Errorf("get schema version: %w", err) return fmt.Errorf("get schema version: %w", err)
} }
if !ok {
if err == redis.Nil { n, err := tx.Count(ctx, rootsNS)
// Sentinel absent. Fresh store iff there's no file:__index__ membership. if err != nil {
n, sErr := c.client.SCard(ctx, c.buildIndexKey()).Result() return fmt.Errorf("count roots: %w", err)
if sErr != nil && sErr != redis.Nil {
return fmt.Errorf("scard file:__index__: %w", sErr)
} }
if n > 0 { if n > 0 {
return fmt.Errorf( return fmt.Errorf(
"redis has %d indexed roots but no %q sentinel — this is alpha "+ "the store has %d indexed roots but no %q sentinel — this is alpha "+
"data from before the UUID-rooted schema landed. Run "+ "data from before the UUID-rooted schema landed. Delete the "+
"`pnpm run clean:all` (or `docker exec metacore-app redis-cli FLUSHDB`) "+ "database file to wipe and restart, or downgrade meta-core to a compatible build",
"to wipe and restart, or downgrade meta-core to a compatible build",
n, SchemaVersionKey) n, SchemaVersionKey)
} }
if err := c.client.Set(ctx, key, strconv.Itoa(expected), 0).Err(); err != nil { return tx.Put(SchemaVersionKey, strconv.Itoa(expected))
return fmt.Errorf("set schema version: %w", err)
} }
return nil
}
if have == strconv.Itoa(expected) { if have == strconv.Itoa(expected) {
return nil return nil
} }
return fmt.Errorf( return fmt.Errorf(
"redis schema version is %q, this build of meta-core expects %d. "+ "store schema version is %q, this build of meta-core expects %d. "+
"Run `pnpm run clean:all` to wipe and restart, or use a meta-core "+ "Delete the database file to wipe and restart, or use a meta-core "+
"build that matches the existing data", "build that matches the existing data",
have, expected) have, expected)
})
} }
+602
View File
@@ -0,0 +1,602 @@
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) {
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 := readRecord(ctx, s, id)
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
}
}
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
}
+355
View File
@@ -0,0 +1,355 @@
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.
func seed(t *testing.T, c *Client, records map[string]map[string]string) {
t.Helper()
for id, fields := range records {
if err := c.SetMetadataFlat(id, fields); err != nil {
t.Fatalf("seed %s: %v", id, err)
}
}
}
// 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"}})
if err := c.SetProperty("R1", "title", "After"); err != nil {
t.Fatal(err)
}
equalIDs(t, search(t, c, "after", nil, textMatch("after")), "R1")
equalIDs(t, search(t, c, "before", nil, textMatch("before")))
if _, err := c.DeleteMetadata("R1"); err != nil {
t.Fatal(err)
}
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, &notused, &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, &notused, &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)
}
}
+47
View File
@@ -0,0 +1,47 @@
package storage
import (
"time"
)
// 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
// `total` on every query.
func (c *Client) CountRoots() (int64, error) {
s, err := c.kv()
if err != nil {
return 0, err
}
ctx, cancel := opCtx()
defer cancel()
n, _, err := s.Counter(ctx, rootsCountKey)
return n, err
}
// keyCountTTL bounds how stale CountKeys may be. Counting every row is a full
// table scan (~a second at millions of keys), and the only reader is a polled
// dashboard tile — it needs a number, not a fresh one every few seconds.
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) {
c.keysMu.Lock()
defer c.keysMu.Unlock()
if !c.keysAt.IsZero() && time.Since(c.keysAt) < keyCountTTL {
return c.keysCount, nil
}
s, err := c.kv()
if err != nil {
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
}
+312 -172
View File
@@ -12,25 +12,31 @@ package storage
// strictly exceeds the stored one (a per-cell high-water-mark, replacing the // 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). // 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): // Key model (all under the literal "udl:" prefix, mirroring the "file:" one):
// //
// udl:rec:<uid>/<cid>/<key> HASH { record, version, ts, value? } // udl:rec:<uid>/<cid>/<key> JSON { 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>: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>:cid:<cid> set of <key> -> list_for_user_cid
// udl:idx:cid:<cid>:key:<key> SET of <uid> -> aggregate // udl:idx:user:<uid>:cids set of <cid> -> whole-profile export (recovery)
// udl:idx:cid:<cid>:users SET of <uid> -> uids_for_cid // 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 // A "set" is a key-set: one key `<set>\x1f<member>` = "" per member, read as
// the key range under `<set>\x1f`. The separator is a control character
// because UDL keys are not validated — with `/` the members of
// `…:key:a/b` would show up as members of `…:key:a`.
//
// The `value` field is the plaintext JSON scalar for PUBLIC keys only
// (like/rating), used for aggregation; it is absent for private-tier keys // (like/rating), used for aggregation; it is absent for private-tier keys
// (whose real value rides encrypted inside `record`) and for tombstones. // (whose real value rides encrypted inside `record`) and for tombstones.
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strconv" "sort"
"time"
"github.com/redis/go-redis/v9" "github.com/metazla/meta-core/internal/kv"
) )
// UDLCell is a decoded (uid, cid, key) record cell. // UDLCell is a decoded (uid, cid, key) record cell.
@@ -66,151 +72,208 @@ func udlIdxUserCid(uid, cid string) string { return fmt.Sprintf("udl:idx:user:%s
func udlIdxCidKey(cid, key string) string { return fmt.Sprintf("udl:idx:cid:%s:key:%s", cid, key) } 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) } func udlIdxCidUsers(cid string) string { return fmt.Sprintf("udl:idx:cid:%s:users", cid) }
// udlUpsertScript performs the version-gated upsert + index maintenance // udlIdxUserCids is the set of every cid a uid has ANY record for — the one
// atomically. Accepts (returns 1) only when the incoming version strictly // index that makes "export this whole profile" a set read instead of a
// exceeds the stored one; otherwise returns 0 (stale). // keyspace scan.
// //
// KEYS: 1=cell 2=idx:user:key 3=idx:user:cid 4=idx:cid:key 5=idx:cid:users // The other four indexes each answer a question with one coordinate already
// ARGV: 1=version 2=ts 3=record 4=value 5=hasValue("1"/"0") // known (a key, a cid). Cross-instance recovery knows neither: it has a uid and
// 6=cid 7=key 8=uid 9=tombstone("1"/"0") // 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 —
// A tombstone still writes the cell — that is the CRDT state, and dropping it // or to SCAN `udl:rec:<uid>/*`, which is the full-keyspace-read pathology that
// would let an older write resurrect the value — but it SREMs the cid from the // already made `/api/udl/users/stats` admin-only.
// user+key index. That index is what `active_for_user_key` returns, so leaving func udlIdxUserCids(uid string) string { return fmt.Sprintf("udl:idx:user:%s:cids", uid) }
// deleted cids in it means every My List / Continue Watching read carries every
// title ever un-liked or finished, forever. The other three indexes are left // udlSetSep separates a set key from a member (see the key model above).
// alone: they answer "who has an opinion about this cid", where a tombstone is const udlSetSep = "\x1f"
// still an answer.
var udlUpsertScript = redis.NewScript(` // udlCellJSON is a cell's stored form. Value is a pointer so an absent value
local stored = redis.call('HGET', KEYS[1], 'version') // (private-tier key, tombstone) stays distinguishable from an empty one.
if stored and tonumber(stored) >= tonumber(ARGV[1]) then type udlCellJSON struct {
return 0 Record string `json:"record"`
end Version int64 `json:"version"`
redis.call('HSET', KEYS[1], 'version', ARGV[1], 'ts', ARGV[2], 'record', ARGV[3]) Ts int64 `json:"ts"`
if ARGV[5] == '1' then Value *string `json:"value,omitempty"`
redis.call('HSET', KEYS[1], 'value', ARGV[4]) }
else
redis.call('HDEL', KEYS[1], 'value') func decodeUDLCell(raw string) (UDLCell, error) {
end var j udlCellJSON
if ARGV[9] == '1' then if err := json.Unmarshal([]byte(raw), &j); err != nil {
redis.call('SREM', KEYS[2], ARGV[6]) return UDLCell{}, err
else }
redis.call('SADD', KEYS[2], ARGV[6]) cell := UDLCell{Record: j.Record, Version: j.Version, Ts: j.Ts}
end if j.Value != nil {
redis.call('SADD', KEYS[3], ARGV[7]) cell.Value, cell.HasValue = *j.Value, true
redis.call('SADD', KEYS[4], ARGV[8]) }
redis.call('SADD', KEYS[5], ARGV[8]) return cell, nil
return 1 }
`)
// udlMembers lists a key-set's members, sorted.
func udlMembers(ctx context.Context, r kv.Reader, set string) ([]string, error) {
prefix := set + udlSetSep
rows, err := r.Range(ctx, prefix, "", 0)
if err != nil {
return nil, err
}
out := make([]string, 0, len(rows))
for _, row := range rows {
out = append(out, row.K[len(prefix):])
}
return out, nil
}
func udlAdd(tx *kv.Tx, set, member string) error {
_, err := tx.PutIfAbsent(set+udlSetSep+member, "")
return err
}
func udlRem(tx *kv.Tx, set, member string) error {
_, err := tx.Delete(set + udlSetSep + member)
return err
}
// readUDLCell reads one cell. exists is false when it was never written.
func readUDLCell(ctx context.Context, r kv.Reader, recKey string) (UDLCell, bool, error) {
raw, ok, err := r.Get(ctx, recKey)
if err != nil || !ok {
return UDLCell{}, false, err
}
cell, err := decodeUDLCell(raw)
if err != nil {
return UDLCell{}, false, fmt.Errorf("decode %s: %w", recKey, err)
}
return cell, true, nil
}
// readUDLCells reads many cells in one multi-key read; absent cells are
// omitted from the map.
func readUDLCells(ctx context.Context, r kv.Reader, recKeys []string) (map[string]UDLCell, error) {
raws, err := r.MGet(ctx, recKeys)
if err != nil {
return nil, err
}
out := make(map[string]UDLCell, len(raws))
for k, raw := range raws {
cell, err := decodeUDLCell(raw)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", k, err)
}
out[k] = cell
}
return out, nil
}
// UDLUpsertIfNewer stores a record cell iff version strictly exceeds the stored // UDLUpsertIfNewer stores a record cell iff version strictly exceeds the stored
// one. publicValue/hasValue carry the plaintext JSON scalar for public keys // one, and maintains the indexes — one transaction, so the gate and the write
// (used by aggregation); pass hasValue=false for private-tier keys and // cannot interleave with a concurrent upsert of the same cell.
// tombstones. Set tombstone=true when the record deletes the cell, which also // publicValue/hasValue carry the plaintext JSON scalar for public keys (used by
// prunes the cid from the user+key index. Returns accepted=false when the write // aggregation); pass hasValue=false for private-tier keys and tombstones. Set
// is stale. // tombstone=true when the record deletes the cell.
//
// A tombstone still writes the cell — that is the CRDT state, and dropping it
// would let an older write resurrect the value — but it removes 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` in particular must NEVER be pruned on a tombstone, even
// though it looks symmetric with the user+key index. 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.
//
// 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) { func (c *Client) UDLUpsertIfNewer(uid, cid, key string, version, ts int64, record, publicValue string, hasValue, tombstone bool) (bool, error) {
c.mu.Lock() accepted := false
defer c.mu.Unlock() err := c.update(func(tx *kv.Tx) error {
recKey := udlRecKey(uid, cid, key)
if c.client == nil { stored, ok, err := readUDLCell(tx.Context(), tx, recKey)
return false, fmt.Errorf("not connected") if err != nil {
return err
} }
if ok && stored.Version >= version {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) return nil
defer cancel()
keys := []string{
c.buildKey(udlRecKey(uid, cid, key)),
c.buildKey(udlIdxUserKey(uid, key)),
c.buildKey(udlIdxUserCid(uid, cid)),
c.buildKey(udlIdxCidKey(cid, key)),
c.buildKey(udlIdxCidUsers(cid)),
} }
hv := "0" cell := udlCellJSON{Record: record, Version: version, Ts: ts}
if hasValue { if hasValue {
hv = "1" v := publicValue
cell.Value = &v
}
body, err := json.Marshal(cell)
if err != nil {
return err
}
if err := tx.Put(recKey, string(body)); err != nil {
return err
} }
tomb := "0"
if tombstone { if tombstone {
tomb = "1" err = udlRem(tx, udlIdxUserKey(uid, key), cid)
} else {
err = udlAdd(tx, udlIdxUserKey(uid, key), cid)
} }
res, err := udlUpsertScript.Run(ctx, c.client, keys, version, ts, record, publicValue, hv, cid, key, uid, tomb).Result() if err != nil {
return err
}
for _, add := range [][2]string{
{udlIdxUserCid(uid, cid), key},
{udlIdxCidKey(cid, key), uid},
{udlIdxCidUsers(cid), uid},
{udlIdxUserCids(uid), cid},
} {
if err := udlAdd(tx, add[0], add[1]); err != nil {
return err
}
}
accepted = true
return nil
})
if err != nil { if err != nil {
return false, fmt.Errorf("udl upsert failed: %w", err) return false, fmt.Errorf("udl upsert failed: %w", err)
} }
n, _ := res.(int64) return accepted, nil
return n == 1, nil
} }
// UDLGetCell returns the record cell for (uid, cid, key). exists is false when // UDLGetCell returns the record cell for (uid, cid, key). exists is false when
// no record has ever been written for the cell. // no record has ever been written for the cell.
func (c *Client) UDLGetCell(uid, cid, key string) (UDLCell, bool, error) { func (c *Client) UDLGetCell(uid, cid, key string) (UDLCell, bool, error) {
c.mu.RLock() s, err := c.kv()
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 UDLCell{}, false, err
} }
// vals: [record, version, ts, value] — nil for absent fields. ctx, cancel := opCtx()
if len(vals) < 4 || vals[1] == nil { defer cancel()
return UDLCell{}, false, nil return readUDLCell(ctx, s, udlRecKey(uid, cid, key))
}
cell := UDLCell{}
if s, ok := vals[0].(string); ok {
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 // UDLListUserKey returns every cell for (uid, key) across all cids — the
// projection behind active_for_user_key (My List, Continue Watching). Includes // projection behind active_for_user_key (My List, Continue Watching). Includes
// tombstones; the caller decodes each record and filters. // tombstones; the caller decodes each record and filters.
func (c *Client) UDLListUserKey(uid, key string) ([]UDLUserKeyEntry, error) { func (c *Client) UDLListUserKey(uid, key string) ([]UDLUserKeyEntry, error) {
c.mu.RLock() s, err := c.kv()
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
} }
ctx, cancel := opCtx()
defer cancel()
cids, err := udlMembers(ctx, s, udlIdxUserKey(uid, key))
if err != nil {
return nil, err
}
recKeys := make([]string, len(cids))
for i, cid := range cids {
recKeys[i] = udlRecKey(uid, cid, key)
}
cells, err := readUDLCells(ctx, s, recKeys)
if err != nil {
return nil, err
}
out := make([]UDLUserKeyEntry, 0, len(cids))
for i, cid := range cids {
cell, ok := cells[recKeys[i]]
if !ok { if !ok {
continue continue
} }
@@ -222,26 +285,28 @@ func (c *Client) UDLListUserKey(uid, key string) ([]UDLUserKeyEntry, error) {
// UDLListUserCid returns every cell for (uid, cid) across all keys — the // UDLListUserCid returns every cell for (uid, cid) across all keys — the
// projection behind list_for_user_cid. // projection behind list_for_user_cid.
func (c *Client) UDLListUserCid(uid, cid string) ([]UDLUserCidEntry, error) { func (c *Client) UDLListUserCid(uid, cid string) ([]UDLUserCidEntry, error) {
c.mu.RLock() s, err := c.kv()
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()
keys, err := c.client.SMembers(ctx, c.buildKey(udlIdxUserCid(uid, cid))).Result()
if err != nil {
return nil, fmt.Errorf("smembers failed: %w", err)
}
out := make([]UDLUserCidEntry, 0, len(keys))
for _, key := range keys {
cell, ok, err := c.udlReadCell(ctx, udlRecKey(uid, cid, key))
if err != nil { if err != nil {
return nil, err return nil, err
} }
ctx, cancel := opCtx()
defer cancel()
keys, err := udlMembers(ctx, s, udlIdxUserCid(uid, cid))
if err != nil {
return nil, err
}
recKeys := make([]string, len(keys))
for i, key := range keys {
recKeys[i] = udlRecKey(uid, cid, key)
}
cells, err := readUDLCells(ctx, s, recKeys)
if err != nil {
return nil, err
}
out := make([]UDLUserCidEntry, 0, len(keys))
for i, key := range keys {
cell, ok := cells[recKeys[i]]
if !ok { if !ok {
continue continue
} }
@@ -250,53 +315,128 @@ func (c *Client) UDLListUserCid(uid, cid string) ([]UDLUserCidEntry, error) {
return out, nil return out, nil
} }
// UDLCidUsers returns every uid that has any record for cid — uids_for_cid. // UDLUserAllEntry is one row of a whole-profile export.
func (c *Client) UDLCidUsers(cid string) ([]string, error) { type UDLUserAllEntry struct {
c.mu.RLock() Cid string `json:"cid"`
defer c.mu.RUnlock() Key string `json:"key"`
Record string `json:"record"`
if c.client == nil { Version int64 `json:"version"`
return nil, fmt.Errorf("not connected") Ts int64 `json:"ts"`
} }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // UDLAllForUser returns every cell a uid owns, for cross-instance recovery.
//
// Ordered by cid, 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) {
s, err := c.kv()
if err != nil {
return nil, "", err
}
if limit <= 0 {
limit = 1000
}
ctx, cancel := opCtx()
defer cancel() defer cancel()
uids, err := c.client.SMembers(ctx, c.buildKey(udlIdxCidUsers(cid))).Result() cids, err := udlMembers(ctx, s, udlIdxUserCids(uid))
if err != nil { if err != nil {
return nil, fmt.Errorf("smembers failed: %w", err) return nil, "", err
} }
return uids, nil sort.Strings(cids)
out := make([]UDLUserAllEntry, 0, limit)
nextCid := ""
for _, cid := range cids {
if afterCid != "" && cid <= afterCid {
continue
}
if len(out) >= limit {
nextCid = out[len(out)-1].Cid
break
}
keys, err := udlMembers(ctx, s, udlIdxUserCid(uid, cid))
if err != nil {
return nil, "", err
}
for _, key := range keys {
cell, ok, err := readUDLCell(ctx, s, 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
}
// UDLCountUserCids is the number of distinct cids a uid holds records for.
//
// One index range count, 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) {
s, err := c.kv()
if err != nil {
return 0, err
}
ctx, cancel := opCtx()
defer cancel()
return s.Count(ctx, udlIdxUserCids(uid)+udlSetSep)
}
// UDLCidUsers returns every uid that has any record for cid — uids_for_cid.
func (c *Client) UDLCidUsers(cid string) ([]string, error) {
s, err := c.kv()
if err != nil {
return nil, err
}
ctx, cancel := opCtx()
defer cancel()
return udlMembers(ctx, s, udlIdxCidUsers(cid))
} }
// UDLPublicValues returns the live plaintext public JSON scalar values for // UDLPublicValues returns the live plaintext public JSON scalar values for
// (cid, key) across all uids — the input to aggregation. Cells without a value // (cid, key) across all uids — the input to aggregation. Cells without a value
// field (private-tier or tombstoned) are skipped. // (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() s, err := c.kv()
defer c.mu.RUnlock() if err != nil {
return nil, err
if c.client == nil {
return nil, fmt.Errorf("not connected")
} }
ctx, cancel := opCtx()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
uids, err := c.client.SMembers(ctx, c.buildKey(udlIdxCidKey(cid, key))).Result() uids, err := udlMembers(ctx, s, udlIdxCidKey(cid, key))
if err != nil { if err != nil {
return nil, fmt.Errorf("smembers failed: %w", err) return nil, err
}
recKeys := make([]string, len(uids))
for i, uid := range uids {
recKeys[i] = udlRecKey(uid, cid, key)
}
cells, err := readUDLCells(ctx, s, recKeys)
if err != nil {
return nil, err
} }
out := make([]string, 0, len(uids)) out := make([]string, 0, len(uids))
for _, uid := range uids { for _, k := range recKeys {
v, err := c.client.HGet(ctx, c.buildKey(udlRecKey(uid, cid, key)), "value").Result() if cell, ok := cells[k]; ok && cell.HasValue {
if err == redis.Nil { out = append(out, cell.Value)
continue // private-tier or tombstoned
} }
if err != nil {
return nil, fmt.Errorf("hget failed: %w", err)
}
out = append(out, v)
} }
return out, nil return out, nil
} }
+179
View File
@@ -0,0 +1,179 @@
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. Keys are ordered, so one account's cells are the key range under
// `udl:rec:<uid>/`, and every account's are the range under `udl:rec:`.
// UDLAllUserStats stays a bulk call (one walk buckets every account) because
// the accounts page needs a number for each.
import (
"fmt"
"strings"
"github.com/metazla/meta-core/internal/kv"
)
// 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"`
}
// safeUIDForPattern rejects anything that would reach across the key
// structure (a `/` would let a uid prefix-match another account's range). 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
}
// UDLUserCells returns every (cid, key) pair this account holds a cell for,
// keyed by the "cid/key" tail with the cid as value (the purge needs them
// grouped).
func (c *Client) UDLUserCells(uid string) (map[string]string, error) {
if err := safeUIDForPattern(uid); err != nil {
return nil, err
}
s, err := c.kv()
if err != nil {
return nil, err
}
ctx, cancel := opCtx()
defer cancel()
out := make(map[string]string)
err = s.ScanPrefix(ctx, "udl:rec:"+uid+"/", readerPage, func(rows []kv.KV) error {
for _, row := range rows {
gotUID, cid, key, ok := splitUDLRecSuffix(strings.TrimPrefix(row.K, "udl:rec:"))
if !ok || gotUID != uid {
continue
}
out[cid+"/"+key] = cid
}
return nil
})
return out, err
}
// UDLAllUserStats counts every account's cells in one 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) {
s, err := c.kv()
if err != nil {
return nil, err
}
ctx, cancel := opCtx()
defer cancel()
type acc struct {
cells int
cids map[string]struct{}
keys map[string]struct{}
}
byUID := make(map[string]*acc)
err = s.ScanPrefix(ctx, "udl:rec:", readerPage, func(rows []kv.KV) error {
for _, row := range rows {
uid, cid, key, ok := splitUDLRecSuffix(strings.TrimPrefix(row.K, "udl:rec:"))
if !ok {
continue
}
a := byUID[uid]
if a == nil {
a = &acc{cids: make(map[string]struct{}), keys: make(map[string]struct{})}
byUID[uid] = a
}
a.cells++
a.cids[cid] = struct{}{}
a.keys[key] = struct{}{}
}
return nil
})
if err != nil {
return nil, err
}
out := make(map[string]UDLUserStats, len(byUID))
for uid, a := range byUID {
out[uid] = UDLUserStats{Records: 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.
//
// Idempotent: purging an account with nothing stored is a no-op returning
// zeroes, so a retry is safe.
func (c *Client) UDLPurgeUser(uid string) (UDLUserStats, error) {
cells, err := c.UDLUserCells(uid)
if err != nil {
return UDLUserStats{}, err
}
stats := UDLUserStats{Records: len(cells)}
distinctCids := make(map[string]struct{})
distinctKeys := make(map[string]struct{})
err = c.update(func(tx *kv.Tx) error {
for cidKey, cid := range cells {
key := strings.TrimPrefix(cidKey, cid+"/")
distinctCids[cid] = struct{}{}
distinctKeys[key] = struct{}{}
if _, err := tx.Delete(udlRecKey(uid, cid, key)); err != nil {
return err
}
if err := udlRem(tx, udlIdxCidKey(cid, key), uid); err != nil {
return err
}
}
for cid := range distinctCids {
if err := udlRem(tx, udlIdxCidUsers(cid), uid); err != nil {
return err
}
}
// The three user-scoped indexes (`:key:*`, `:cid:*`, `:cids`) are one
// key range, dropped whole — which also catches index entries whose
// cell is already gone (the state a half-finished purge leaves).
_, err := tx.DeletePrefix("udl:idx:user:"+uid+":", 0)
return err
})
if err != nil {
return UDLUserStats{}, fmt.Errorf("purge: %w", err)
}
stats.Cids = len(distinctCids)
stats.Keys = len(distinctKeys)
return stats, nil
}
+233
View File
@@ -0,0 +1,233 @@
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)
}
}
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)
}
}
}
+160
View File
@@ -0,0 +1,160 @@
package storage
import (
"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
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// forever. The reader filters them out again, so the cost is invisible until // forever. The reader filters them out again, so the cost is invisible until
// the account is old. // the account is old.
func TestUDLUpsert_TombstonePrunesUserKeyIndexButKeepsCell(t *testing.T) { func TestUDLUpsert_TombstonePrunesUserKeyIndexButKeepsCell(t *testing.T) {
c, _ := newTestClient(t, "") c := newTestClient(t)
const ( const (
uid = "zAlice" uid = "zAlice"
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// layout). Used as the root key for file metadata entries. // layout). Used as the root key for file metadata entries.
// //
// Why this format over canonical 8-4-4-4-12 hex: // Why this format over canonical 8-4-4-4-12 hex:
// - 26 chars vs 36 — shorter Redis keys, less prefix space waste // - 26 chars vs 36 — shorter keys, less prefix space waste
// - Time-sortable prefix — KEYS file:01JKR* returns entries minted in a // - Time-sortable prefix — KEYS file:01JKR* returns entries minted in a
// given time window, restoring some of the debug-ability lost vs the // given time window, restoring some of the debug-ability lost vs the
// previous midhash256:abc rooting // previous midhash256:abc rooting
+109
View File
@@ -0,0 +1,109 @@
package storage
import (
"fmt"
"path/filepath"
"sync"
"sync/atomic"
"testing"
)
// BenchmarkConcurrentRecordWrites is the ingest shape the single-writer model
// has to absorb: many goroutines (feeder flushes, gateway persists, plugin
// callbacks) each writing a ~40-field record. SQLite serialises them; this
// measures what that costs. Reported as records/s.
func BenchmarkConcurrentRecordWrites(b *testing.B) {
c := NewClient()
if err := c.Connect(filepath.Join(b.TempDir(), "bench.db")); err != nil {
b.Fatal(err)
}
defer c.Close()
record := func(i int64) map[string]string {
m := map[string]string{
"title": fmt.Sprintf("Some Show S01E%02d", i%40),
"fileName": fmt.Sprintf("some.show.s01e%02d.1080p.mkv", i%40),
"filePath": fmt.Sprintf("/files/watch/some.show.%d.mkv", i),
"fileType": "video",
"tmdbid": fmt.Sprintf("%d", 1000+i%500),
fmt.Sprintf("cids/bafkbench%020d", i): "true",
}
for f := 0; len(m) < 40; f++ {
m[fmt.Sprintf("categories/%d", f)] = "true"
}
return m
}
const writers = 16
var next int64
b.ResetTimer()
var wg sync.WaitGroup
for w := 0; w < writers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
i := atomic.AddInt64(&next, 1)
if i > int64(b.N) {
return
}
if err := c.SetMetadataFlat(fmt.Sprintf("01BENCH%019d", i), record(i)); err != nil {
b.Error(err)
return
}
}
}()
}
wg.Wait()
b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "records/s")
}
// BenchmarkConcurrentRepersistUnchanged is the dominant write on a permanent
// store: a gateway re-persisting hits it already holds. The kv layer skips
// every unchanged field, so this should cost reads only.
func BenchmarkConcurrentRepersistUnchanged(b *testing.B) {
c := NewClient()
if err := c.Connect(filepath.Join(b.TempDir(), "bench.db")); err != nil {
b.Fatal(err)
}
defer c.Close()
const distinct = 2000
rec := func(i int64) map[string]string {
m := map[string]string{
"title": fmt.Sprintf("Some Show S01E%02d", i%40),
"fileType": "video",
fmt.Sprintf("cids/bafkbench%020d", i): "true",
}
for f := 0; len(m) < 40; f++ {
m[fmt.Sprintf("categories/%d", f)] = "true"
}
return m
}
for i := int64(0); i < distinct; i++ {
if err := c.SetMetadataFlat(fmt.Sprintf("01BENCH%019d", i), rec(i)); err != nil {
b.Fatal(err)
}
}
const writers = 16
var next int64
b.ResetTimer()
var wg sync.WaitGroup
for w := 0; w < writers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
i := atomic.AddInt64(&next, 1)
if i > int64(b.N) {
return
}
if err := c.SetMetadataFlat(fmt.Sprintf("01BENCH%019d", i%distinct), rec(i%distinct)); err != nil {
b.Error(err)
return
}
}
}()
}
wg.Wait()
b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "records/s")
}
+21 -13
View File
@@ -11,7 +11,7 @@ import (
) )
const ( const (
// EventsStream is the Redis Stream name for file events // EventsStream is the stream name for file events
EventsStream = "file:events" EventsStream = "file:events"
// EventsStreamMaxLen is the approximate cap on stream length. Bounded // EventsStreamMaxLen is the approximate cap on stream length. Bounded
@@ -20,7 +20,7 @@ const (
EventsStreamMaxLen = 100_000 EventsStreamMaxLen = 100_000
) )
// Dispatcher sends file events to Redis Stream // Dispatcher sends file events to the file:events stream
type Dispatcher struct { type Dispatcher struct {
mu sync.RWMutex mu sync.RWMutex
storageClient *storage.Client storageClient *storage.Client
@@ -31,27 +31,37 @@ func NewDispatcher() *Dispatcher {
return &Dispatcher{} return &Dispatcher{}
} }
// SetStorageClient sets the storage client for Redis Stream publishing // SetStorageClient sets the storage client for file:events publishing
func (d *Dispatcher) SetStorageClient(client *storage.Client) { func (d *Dispatcher) SetStorageClient(client *storage.Client) {
d.mu.Lock() d.mu.Lock()
defer d.mu.Unlock() defer d.mu.Unlock()
d.storageClient = client d.storageClient = client
log.Println("[Dispatcher] Storage client set for Redis Stream publishing") log.Println("[Dispatcher] Storage client set for file:events publishing")
} }
// Dispatch sends an event to Redis Stream // Dispatch sends an event to the file:events stream
func (d *Dispatcher) Dispatch(event FileEvent) { func (d *Dispatcher) Dispatch(event FileEvent) {
go d.dispatchToStream(event) go d.dispatchToStream(event)
} }
// dispatchToStream publishes event to Redis Stream for service consumption // dispatchToStream publishes event to the file:events stream for service consumption
func (d *Dispatcher) dispatchToStream(event FileEvent) { func (d *Dispatcher) dispatchToStream(event FileEvent) {
d.mu.RLock() d.mu.RLock()
client := d.storageClient client := d.storageClient
d.mu.RUnlock() d.mu.RUnlock()
if err := PublishEvent(client, event); err != nil {
log.Printf("[Dispatcher] Failed to publish to stream: %v", err)
}
}
// PublishEvent appends one file event to the file:events stream. Exported so a
// caller with no Dispatcher (POST /api/files/register on a box whose watcher is
// off) emits exactly the entry the watcher would — meta-share's ipfs_seed
// consumes this stream to chunk, seed and provide new files.
func PublishEvent(client *storage.Client, event FileEvent) error {
if client == nil || !client.IsConnected() { if client == nil || !client.IsConnected() {
return return fmt.Errorf("storage client not connected")
} }
// Build stream fields // Build stream fields
@@ -79,9 +89,7 @@ func (d *Dispatcher) dispatchToStream(event FileEvent) {
// `EmitReset → ClearStream` path is still the only way the stream gets // `EmitReset → ClearStream` path is still the only way the stream gets
// truncated to zero (signalled to consumers via the `reset` event). // truncated to zero (signalled to consumers via the `reset` event).
_, err := client.XAdd(EventsStream, EventsStreamMaxLen, fields) _, err := client.XAdd(EventsStream, EventsStreamMaxLen, fields)
if err != nil { return err
log.Printf("[Dispatcher] Failed to publish to stream: %v", err)
}
} }
// EmitReset clears the stream and emits a reset event // EmitReset clears the stream and emits a reset event
@@ -117,7 +125,7 @@ func (d *Dispatcher) EmitReset(watcherID string) error {
return nil return nil
} }
// GetRecentEvents reads recent events from the Redis stream, newest first. // GetRecentEvents reads recent events from the stream, newest first.
// If sinceMS > 0, only events strictly newer than that timestamp are returned. // If sinceMS > 0, only events strictly newer than that timestamp are returned.
// limit caps the number of returned events (defaults to 100 when <= 0). // limit caps the number of returned events (defaults to 100 when <= 0).
func (d *Dispatcher) GetRecentEvents(sinceMS int64, limit int) ([]FileEvent, error) { func (d *Dispatcher) GetRecentEvents(sinceMS int64, limit int) ([]FileEvent, error) {
@@ -152,7 +160,7 @@ func (d *Dispatcher) GetRecentEvents(sinceMS int64, limit int) ([]FileEvent, err
return events, nil return events, nil
} }
// parseStreamEntry converts a Redis stream entry back into a FileEvent. // parseStreamEntry converts a stream entry back into a FileEvent.
// All fields are stored as strings by XADD, so numerics need parsing. // All fields are stored as strings by XADD, so numerics need parsing.
func parseStreamEntry(entry storage.StreamEntry) FileEvent { func parseStreamEntry(entry storage.StreamEntry) FileEvent {
event := FileEvent{} event := FileEvent{}
@@ -184,7 +192,7 @@ func parseStreamEntry(entry storage.StreamEntry) FileEvent {
} }
// Fall back to the stream ID's millisecond prefix if the timestamp // Fall back to the stream ID's millisecond prefix if the timestamp
// field is missing or unparseable — every Redis stream ID starts with ms. // field is missing or unparseable — every stream ID starts with ms.
if event.Timestamp == 0 && entry.ID != "" { if event.Timestamp == 0 && entry.ID != "" {
if dash := strings.IndexByte(entry.ID, '-'); dash > 0 { if dash := strings.IndexByte(entry.ID, '-'); dash > 0 {
if n, err := strconv.ParseInt(entry.ID[:dash], 10, 64); err == nil { if n, err := strconv.ParseInt(entry.ID[:dash], 10, 64); err == nil {
+2 -2
View File
@@ -25,7 +25,7 @@ func NewHandlers(watcher *Watcher, dispatcher *Dispatcher) *Handlers {
// RegisterRoutes registers all watcher-related routes // RegisterRoutes registers all watcher-related routes
func (h *Handlers) RegisterRoutes(r *mux.Router) { func (h *Handlers) RegisterRoutes(r *mux.Router) {
// Event polling (for services that don't use Redis Streams) // Event polling (for services that don't consume the SSE stream)
r.HandleFunc("/api/events/poll", h.handlePoll).Methods("GET") r.HandleFunc("/api/events/poll", h.handlePoll).Methods("GET")
// Deprecated scan endpoints - redirect to /api/watchers // Deprecated scan endpoints - redirect to /api/watchers
@@ -61,7 +61,7 @@ func (h *Handlers) handlePoll(w http.ResponseWriter, r *http.Request) {
} }
} }
// Read events from the Redis `file:events` stream rather than the // Read events from the `file:events` stream rather than the
// in-memory buffer. The buffer is fed by the unused debouncer path, // in-memory buffer. The buffer is fed by the unused debouncer path,
// so it is always empty in practice — the stream is the source of truth. // so it is always empty in practice — the stream is the source of truth.
events, err := h.dispatcher.GetRecentEvents(sinceMS, limit) events, err := h.dispatcher.GetRecentEvents(sinceMS, limit)
+8 -45
View File
@@ -4,7 +4,6 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
@@ -12,7 +11,6 @@ import (
"github.com/metazla/meta-core/internal/storage" "github.com/metazla/meta-core/internal/storage"
) )
// Watcher provides file scanning and event dispatching // Watcher provides file scanning and event dispatching
type Watcher struct { type Watcher struct {
config *config.Config config *config.Config
@@ -27,8 +25,8 @@ type Watcher struct {
} }
// SetStorageClient lets the watcher persist the (path, size, mtime, midhash) // SetStorageClient lets the watcher persist the (path, size, mtime, midhash)
// tuple to Redis whenever it computes a midhash. Without this, only the // tuple to the store whenever it computes a midhash. Without this, only the
// in-memory state registry knows about the file — Redis would only learn // in-memory state registry knows about the file — the store would only learn
// about it via downstream consumers of the event stream. // about it via downstream consumers of the event stream.
func (w *Watcher) SetStorageClient(s *storage.Client) { func (w *Watcher) SetStorageClient(s *storage.Client) {
w.storage = s w.storage = s
@@ -36,7 +34,7 @@ func (w *Watcher) SetStorageClient(s *storage.Client) {
// writeFileTuple persists path/size/mtime/midhash for a freshly-hashed file. // writeFileTuple persists path/size/mtime/midhash for a freshly-hashed file.
// Caller already has all four values from its existing os.Stat / FileInfo — // Caller already has all four values from its existing os.Stat / FileInfo —
// no extra IO is performed here beyond the Redis writes themselves. // no extra IO is performed here beyond the store writes themselves.
// //
// Flow: // Flow:
// - Look up the midhash CID in the reverse index (cid:<midhash> → uuid) // - Look up the midhash CID in the reverse index (cid:<midhash> → uuid)
@@ -47,49 +45,15 @@ func (w *Watcher) SetStorageClient(s *storage.Client) {
// record the new path in the duplicates set and refresh size/mtime // record the new path in the duplicates set and refresh size/mtime
// //
// This is also the inverse-index that lets a fresh meta-core boot skip // This is also the inverse-index that lets a fresh meta-core boot skip
// re-mid-hashing files whose (path, size, mtime) match what's in Redis, // re-mid-hashing files whose (path, size, mtime) match what's stored,
// via HydrateStateFromStorage below. // via HydrateStateFromStorage below.
func (w *Watcher) writeFileTuple(absPath string, size, mtimeNano int64, midhash string) { func (w *Watcher) writeFileTuple(absPath string, size, mtimeNano int64, midhash string) {
if w.storage == nil || midhash == "" { if w.storage == nil || midhash == "" {
return return
} }
uuid, err := w.storage.GetByCID(midhash) if _, _, err := w.storage.RegisterFileTuple(absPath, size, mtimeNano, midhash); err != nil {
if err != nil { log.Printf("[Watcher] register %s: %v", absPath, err)
log.Printf("[Watcher] GetByCID(%s): %v", midhash, err)
return
}
if uuid == "" {
// New content. Mint sets filePath/sizeByte/mtimeNano; SetProperty
// then writes the midhash as a cids/<cid> key-set member — and the
// cid_resolution.go hook registers the reverse-index alias for us.
uuid, err = w.storage.Mint(absPath, size, mtimeNano)
if err != nil {
log.Printf("[Watcher] Mint(%s): %v", absPath, err)
return
}
if err := w.storage.SetProperty(uuid, storage.CIDsKeyPrefix+midhash, "true"); err != nil {
log.Printf("[Watcher] SetProperty cids/%s for %s: %v", midhash, uuid, err)
}
return
}
// Known content. If this is a new physical path for the same content,
// record it as a duplicate. Otherwise refresh size/mtime — same content,
// same path, but the file may have been rewritten with a fresh mtime.
existing, _ := w.storage.GetProperty(uuid, "filePath")
if existing != absPath {
if _, err := w.storage.AddDuplicatePath(uuid, absPath); err != nil {
log.Printf("[Watcher] AddDuplicatePath %s @ %s: %v", uuid, absPath, err)
}
return
}
if _, err := w.storage.MergeMetadataFlat(uuid, map[string]string{
"sizeByte": strconv.FormatInt(size, 10),
"mtimeNano": strconv.FormatInt(mtimeNano, 10),
}); err != nil {
log.Printf("[Watcher] refresh tuple for %s: %v", uuid, err)
} }
} }
@@ -305,7 +269,7 @@ func (w *Watcher) ScanDirectoryDiff(root string, watcherID string) DiffScanResul
} }
// WatcherRoot pairs a watcher's id with the absolute path it watches. The // WatcherRoot pairs a watcher's id with the absolute path it watches. The
// hydrator uses Root to decide which Redis-tracked file belongs to which // hydrator uses Root to decide which stored file belongs to which
// in-memory WatcherState. // in-memory WatcherState.
type WatcherRoot struct { type WatcherRoot struct {
ID string ID string
@@ -313,7 +277,7 @@ type WatcherRoot struct {
} }
// HydrateStateFromStorage repopulates the per-watcher in-memory state from // HydrateStateFromStorage repopulates the per-watcher in-memory state from
// the (filePath, sizeByte, mtimeNano) tuples in Redis. After this runs, the // the (filePath, sizeByte, mtimeNano) tuples in the store. After this runs, the
// next scan tick will see existing files as "known" and skip // next scan tick will see existing files as "known" and skip
// ComputeMidHash256 for any whose size+mtime match — turning a cold-start // ComputeMidHash256 for any whose size+mtime match — turning a cold-start
// rescan from minutes to seconds on a populated library. // rescan from minutes to seconds on a populated library.
@@ -451,4 +415,3 @@ func (w *Watcher) GetRecentEvents(sinceMS int64, limit int) []FileEvent {
func (w *Watcher) GetFilesPath() string { func (w *Watcher) GetFilesPath() string {
return w.filesPath return w.filesPath
} }
+5 -23
View File
@@ -2,29 +2,20 @@ package watchers
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
// RepublishCallback is a function type for republishing metadata events
type RepublishCallback func() (int, error)
// Handlers provides HTTP handlers for watchers management // Handlers provides HTTP handlers for watchers management
type Handlers struct { type Handlers struct {
manager *Manager manager *Manager
poller *Poller poller *Poller
republishCallback RepublishCallback
} }
// NewHandlers creates new watchers handlers // NewHandlers creates new watchers handlers
func NewHandlers(manager *Manager, poller *Poller, republishCallback RepublishCallback) *Handlers { func NewHandlers(manager *Manager, poller *Poller) *Handlers {
return &Handlers{ return &Handlers{manager: manager, poller: poller}
manager: manager,
poller: poller,
republishCallback: republishCallback,
}
} }
// RegisterRoutes registers all watchers-related routes // RegisterRoutes registers all watchers-related routes
@@ -215,22 +206,13 @@ func (h *Handlers) handleResetAll(w http.ResponseWriter, r *http.Request) {
return return
} }
// Republish all metadata events after reset // No metadata republish: a reset rescans files; it does not change
republishedCount := 0 // metadata consumers hold. A consumer that wants the whole state asks
if h.republishCallback != nil { // /api/events/meta for "0" and gets a snapshot.
count, err := h.republishCallback()
if err != nil {
log.Printf("[Handlers] Warning: failed to republish metadata after reset: %v", err)
} else {
republishedCount = count
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok", "status": "ok",
"message": "All watchers reset", "message": "All watchers reset",
"totalFiles": totalCount, "totalFiles": totalCount,
"republishedEvents": republishedCount,
}) })
} }
+2 -2
View File
@@ -74,7 +74,7 @@ func (p *Poller) Start() error {
// consumer state without buying anything. The historical reason for // consumer state without buying anything. The historical reason for
// wiping — "in-memory state is empty, all files re-emit as add" — // wiping — "in-memory state is empty, all files re-emit as add" —
// stopped being true when HydrateStateFromStorage landed: state is // stopped being true when HydrateStateFromStorage landed: state is
// hydrated from Redis before this runs, so a startup EmitReset would // hydrated from the store before this runs, so a startup EmitReset would
// tell consumers to throw out their world without sending the // tell consumers to throw out their world without sending the
// re-emission they need to rebuild it. // re-emission they need to rebuild it.
// //
@@ -308,7 +308,7 @@ func (p *Poller) TriggerScanAll() (int, error) {
} }
// TriggerReset performs a full reset for a watcher: // TriggerReset performs a full reset for a watcher:
// 1. Clears the Redis stream and emits a reset event // 1. Clears the file:events stream and emits a reset event
// 2. Clears the watcher's state // 2. Clears the watcher's state
// 3. Performs a full scan (all files are "new") // 3. Performs a full scan (all files are "new")
func (p *Poller) TriggerReset(watcherID string) (int, error) { func (p *Poller) TriggerReset(watcherID string) (int, error) {
+7 -14
View File
@@ -30,8 +30,8 @@ func TestConfigDefaults(t *testing.T) {
t.Errorf("Expected HTTPPort 9000, got %d", cfg.HTTPPort) t.Errorf("Expected HTTPPort 9000, got %d", cfg.HTTPPort)
} }
if cfg.RedisPort != 6379 { if cfg.DBPath() != cfg.MetaCorePath+"/db/meta-core.db" {
t.Errorf("Expected RedisPort 6379, got %d", cfg.RedisPort) t.Errorf("Expected DBPath under MetaCorePath, got %q", cfg.DBPath())
} }
} }
@@ -41,7 +41,7 @@ func TestConfigFromEnv(t *testing.T) {
os.Setenv("FILES_PATH", "/custom/files") os.Setenv("FILES_PATH", "/custom/files")
os.Setenv("SERVICE_NAME", "test-service") os.Setenv("SERVICE_NAME", "test-service")
os.Setenv("META_CORE_HTTP_PORT", "8080") os.Setenv("META_CORE_HTTP_PORT", "8080")
os.Setenv("REDIS_PORT", "6380") os.Setenv("META_CORE_DB_PATH", "/custom/db.sqlite")
defer os.Clearenv() defer os.Clearenv()
cfg := config.Load() cfg := config.Load()
@@ -62,8 +62,8 @@ func TestConfigFromEnv(t *testing.T) {
t.Errorf("Expected HTTPPort 8080, got %d", cfg.HTTPPort) t.Errorf("Expected HTTPPort 8080, got %d", cfg.HTTPPort)
} }
if cfg.RedisPort != 6380 { if cfg.DBPath() != "/custom/db.sqlite" {
t.Errorf("Expected RedisPort 6380, got %d", cfg.RedisPort) t.Errorf("Expected DBPath '/custom/db.sqlite', got %q", cfg.DBPath())
} }
} }
@@ -77,15 +77,8 @@ func TestConfigPaths(t *testing.T) {
t.Errorf("Unexpected LockFilePath: %s", cfg.LockFilePath()) t.Errorf("Unexpected LockFilePath: %s", cfg.LockFilePath())
} }
if cfg.InfoFilePath() != "/test/locks/kv-leader.info" { if cfg.DBPath() != "/test/db/meta-core.db" {
t.Errorf("Unexpected InfoFilePath: %s", cfg.InfoFilePath()) t.Errorf("Unexpected DBPath: %s", cfg.DBPath())
} }
if cfg.RedisDataDir() != "/test/db/redis" {
t.Errorf("Unexpected RedisDataDir: %s", cfg.RedisDataDir())
}
if cfg.ServicesDir() != "/test/services" {
t.Errorf("Unexpected ServicesDir: %s", cfg.ServicesDir())
}
} }
-144
View File
@@ -1,144 +0,0 @@
package test
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/metazla/meta-core/internal/leader"
)
func TestLeaderLockInfoJSON(t *testing.T) {
info := &leader.LeaderLockInfo{
Host: "test-host",
API: "redis://10.0.0.1:6379",
HTTP: "http://10.0.0.1:8180",
BaseURL: "http://localhost:8180",
Timestamp: 1704067200000,
PID: 12345,
}
// Marshal to JSON
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
// Verify JSON structure matches TypeScript format
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if parsed["host"] != "test-host" {
t.Errorf("Expected host 'test-host', got '%v'", parsed["host"])
}
if parsed["api"] != "redis://10.0.0.1:6379" {
t.Errorf("Expected api 'redis://10.0.0.1:6379', got '%v'", parsed["api"])
}
if parsed["http"] != "http://10.0.0.1:8180" {
t.Errorf("Expected http 'http://10.0.0.1:8180', got '%v'", parsed["http"])
}
if parsed["baseUrl"] != "http://localhost:8180" {
t.Errorf("Expected baseUrl 'http://localhost:8180', got '%v'", parsed["baseUrl"])
}
// Timestamp should be a number
if _, ok := parsed["timestamp"].(float64); !ok {
t.Errorf("Expected timestamp to be a number, got %T", parsed["timestamp"])
}
// PID should be a number
if _, ok := parsed["pid"].(float64); !ok {
t.Errorf("Expected pid to be a number, got %T", parsed["pid"])
}
}
func TestLeaderLockInfoJSONOmitEmpty(t *testing.T) {
info := &leader.LeaderLockInfo{
Host: "test-host",
API: "redis://10.0.0.1:6379",
HTTP: "http://10.0.0.1:8180",
BaseURL: "", // Empty - should be omitted
Timestamp: 1704067200000,
PID: 12345,
}
data, err := json.Marshal(info)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
// baseUrl should not be present when empty
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if _, exists := parsed["baseUrl"]; exists {
t.Error("Expected baseUrl to be omitted when empty")
}
}
func TestLeaderInfoFileFormat(t *testing.T) {
// Create a temp directory
tmpDir, err := os.MkdirTemp("", "meta-core-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
info := &leader.LeaderLockInfo{
Host: "test-host",
API: "redis://10.0.0.1:6379",
HTTP: "http://10.0.0.1:8180",
Timestamp: 1704067200000,
PID: 12345,
}
// Write to file
infoPath := filepath.Join(tmpDir, "kv-leader.info")
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
if err := os.WriteFile(infoPath, data, 0644); err != nil {
t.Fatalf("Failed to write file: %v", err)
}
// Read and parse
readData, err := os.ReadFile(infoPath)
if err != nil {
t.Fatalf("Failed to read file: %v", err)
}
var parsed leader.LeaderLockInfo
if err := json.Unmarshal(readData, &parsed); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if parsed.Host != info.Host {
t.Errorf("Host mismatch: expected '%s', got '%s'", info.Host, parsed.Host)
}
if parsed.API != info.API {
t.Errorf("API mismatch: expected '%s', got '%s'", info.API, parsed.API)
}
if parsed.HTTP != info.HTTP {
t.Errorf("HTTP mismatch: expected '%s', got '%s'", info.HTTP, parsed.HTTP)
}
if parsed.Timestamp != info.Timestamp {
t.Errorf("Timestamp mismatch: expected %d, got %d", info.Timestamp, parsed.Timestamp)
}
if parsed.PID != info.PID {
t.Errorf("PID mismatch: expected %d, got %d", info.PID, parsed.PID)
}
}