Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebc894fa42 | ||
|
|
515ec04369 |
@@ -17,10 +17,10 @@ HTTP API.
|
||||
|---------|-------------|
|
||||
| **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. |
|
||||
| **Redis owner** | Supervisord starts Redis with AOF + RDB persistence inside the container; meta-core connects to it on localhost. |
|
||||
| **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, 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. |
|
||||
| **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** | meta-discovery v1: UDP multicast announce/listen on `239.255.77.1:9399`, 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`. |
|
||||
| **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. |
|
||||
@@ -167,12 +167,43 @@ GET /health # storage + role
|
||||
GET /status # version, uptime, etc.
|
||||
GET /leader # current leader info (hostname, PID, timestamps)
|
||||
GET /urls # baseUrl, apiUrl, webdavUrl, webdavUrlInternal (redisUrl is intentionally empty)
|
||||
GET /services # registered services + heartbeats
|
||||
GET /api/services # alias for /services
|
||||
GET /services/{name} # one service
|
||||
GET /services/cleanup/stats
|
||||
GET /neighbors # neighbours heard over UDP (meta-discovery v1)
|
||||
GET /api/neighbors # same, under /api
|
||||
GET /api/stats # dashboard counters (see below)
|
||||
```
|
||||
|
||||
`/services`, `/api/services`, `/services/{name}` and `/services/cleanup/stats`
|
||||
are **gone** — the registration-and-heartbeat registry they served was replaced
|
||||
by meta-discovery v1 (`/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, // SCARD file:__index__ — metadata roots, file or not
|
||||
"redisKeys": 3115447, // DBSIZE — the flat per-field keys dominate
|
||||
"redisMemory": "588.74M",
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
The first four are O(1) commands and are live on every call. `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 Redis 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
|
||||
|
||||
These are the routes other services use. `{hash}` may be a UUID root or
|
||||
|
||||
Vendored
+1
-1
@@ -21,7 +21,7 @@
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-BW9EQGCP.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-Bgo6moAc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DtR_2V4s.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getJSON } from '../lib/api';
|
||||
|
||||
interface HealthStatus {
|
||||
status: string;
|
||||
@@ -6,6 +7,22 @@ interface HealthStatus {
|
||||
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 {
|
||||
id: string;
|
||||
path: string;
|
||||
@@ -22,13 +39,6 @@ interface WatchersResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ServiceInfo {
|
||||
name: string;
|
||||
api: string;
|
||||
capabilities: string[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: '#16213e',
|
||||
borderRadius: '8px',
|
||||
@@ -38,53 +48,110 @@ const cardStyle: React.CSSProperties = {
|
||||
|
||||
const gridStyle: React.CSSProperties = {
|
||||
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',
|
||||
marginBottom: '1rem',
|
||||
};
|
||||
|
||||
const statStyle: React.CSSProperties = {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
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() {
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [watchersData, setWatchersData] = useState<WatchersResponse | null>(null);
|
||||
const [services, setServices] = useState<ServiceInfo[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch health
|
||||
const healthRes = await fetch('/health');
|
||||
if (healthRes.ok) {
|
||||
setHealth(await healthRes.json());
|
||||
}
|
||||
// Settled, not all: one dead endpoint must not blank every tile.
|
||||
const [healthRes, statsRes, watchersRes] = await Promise.allSettled([
|
||||
getJSON<HealthStatus>('/health'),
|
||||
getJSON<Stats>('/api/stats'),
|
||||
getJSON<WatchersResponse>('/api/watchers'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
// Fetch watchers status
|
||||
const watchersRes = await fetch('/api/watchers');
|
||||
if (watchersRes.ok) {
|
||||
setWatchersData(await watchersRes.json());
|
||||
}
|
||||
if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
|
||||
if (statsRes.status === 'fulfilled') setStats(statsRes.value);
|
||||
if (watchersRes.status === 'fulfilled') setWatchersData(watchersRes.value);
|
||||
|
||||
// Fetch services
|
||||
const servicesRes = await fetch('/services');
|
||||
if (servicesRes.ok) {
|
||||
const data = await servicesRes.json();
|
||||
setServices(data.services || []);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
const failures = [healthRes, statsRes, watchersRes]
|
||||
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
|
||||
.map((r) => String(r.reason?.message ?? r.reason));
|
||||
setError(failures.length ? failures.join(' · ') : null);
|
||||
};
|
||||
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 5000);
|
||||
return () => clearInterval(interval);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTriggerScan = async () => {
|
||||
@@ -95,49 +162,102 @@ export default function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
// Aggregate watcher stats
|
||||
const totalFiles = watchersData?.watchers.reduce((sum, w) => sum + w.fileCount, 0) || 0;
|
||||
const isScanning = watchersData?.watchers.some(w => w.isScanning) || false;
|
||||
const activeWatchers = watchersData?.watchers.filter(w => w.active).length || 0;
|
||||
const lastScan = watchersData?.watchers.reduce((max, w) => Math.max(max, w.lastScan || 0), 0) || 0;
|
||||
const isScanning = watchersData?.watchers.some((w) => w.isScanning) || false;
|
||||
const activeWatchers = watchersData?.watchers.filter((w) => w.active).length || 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 (
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
<div style={{ ...cardStyle, background: '#4a1a1a', color: '#f87171' }}>
|
||||
Error: {error}
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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="Redis keys"
|
||||
value={formatCount(stats?.redisKeys)}
|
||||
note={stats?.redisMemory ? `${stats.redisMemory} in use` : 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}>
|
||||
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Service Health</h3>
|
||||
<div style={labelStyle}>Service health</div>
|
||||
{health ? (
|
||||
<>
|
||||
<div style={statStyle}>
|
||||
{health.status === 'ok' ? 'Healthy' : health.status}
|
||||
</div>
|
||||
<p>Redis: {health.redis ? 'Connected' : 'Disconnected'}</p>
|
||||
<p style={noteStyle}>
|
||||
Redis: {health.redis ? 'Connected' : 'Disconnected'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>Loading...</p>
|
||||
<p style={noteStyle}>Loading…</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Watchers Status Card */}
|
||||
<div style={cardStyle}>
|
||||
<h3 style={{ marginBottom: '1rem', color: '#888' }}>File Watchers</h3>
|
||||
<div style={labelStyle}>File watchers</div>
|
||||
{watchersData ? (
|
||||
<>
|
||||
<div style={statStyle}>{totalFiles}</div>
|
||||
<p>Files discovered</p>
|
||||
<p>Watchers: {activeWatchers} active / {watchersData.count} total</p>
|
||||
<p>Status: {isScanning ? 'Scanning...' : 'Idle'}</p>
|
||||
<div style={statStyle}>
|
||||
{activeWatchers} / {watchersData.count}
|
||||
</div>
|
||||
<p style={noteStyle}>active watchers</p>
|
||||
<p style={noteStyle}>Status: {isScanning ? 'Scanning…' : 'Idle'}</p>
|
||||
{lastScan > 0 && (
|
||||
<p>Last scan: {new Date(lastScan).toLocaleString()}</p>
|
||||
<p style={noteStyle}>Last scan: {new Date(lastScan).toLocaleString()}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={handleTriggerScan}
|
||||
@@ -155,27 +275,7 @@ export default function Dashboard() {
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p>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>
|
||||
<p style={noteStyle}>Loading…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -276,9 +276,8 @@ Bootstrap / discovery
|
||||
GET /status
|
||||
GET /leader
|
||||
GET /urls
|
||||
GET /services (alias: /api/services)
|
||||
GET /services/{name}
|
||||
GET /services/cleanup/stats
|
||||
GET /neighbors (alias: /api/neighbors)
|
||||
GET /api/stats
|
||||
|
||||
Metadata (primary surface for other services)
|
||||
GET /meta
|
||||
|
||||
@@ -50,6 +50,9 @@ type Server struct {
|
||||
mesh *meshdisco.Node
|
||||
router *mux.Router
|
||||
server *http.Server
|
||||
// stats caches the keyspace-walk half of GET /api/stats so a polling
|
||||
// dashboard never puts a multi-second Redis walk on the request path.
|
||||
stats *statsCache
|
||||
}
|
||||
|
||||
// SetMeshNode attaches the UDP discovery node. Separate from NewServer so the
|
||||
@@ -70,6 +73,7 @@ func NewServer(
|
||||
storage: stor,
|
||||
search: &searchIndex{},
|
||||
router: mux.NewRouter(),
|
||||
stats: &statsCache{},
|
||||
}
|
||||
|
||||
// Initialize file watcher/scanner and dispatcher
|
||||
@@ -169,6 +173,10 @@ func (s *Server) setupRoutes() {
|
||||
s.router.HandleFunc("/leader", s.handleLeader).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)
|
||||
s.router.HandleFunc("/api/metadata/hash-ids", s.handleGetHashIds).Methods("GET")
|
||||
s.router.HandleFunc("/api/metadata/list", s.handleListMetadata).Methods("GET")
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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
|
||||
// O(1) commands (SCARD, DBSIZE, INFO) plus a directory listing, recomputed on
|
||||
// every call. 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 3s Redis 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
|
||||
// (Redis 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
|
||||
}
|
||||
@@ -157,6 +157,42 @@ func (c *Client) addAliasLocked(ctx context.Context, uuid, cidStr string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeAliasLocked is the inverse of addAliasLocked: it drops the reverse-index
|
||||
// entry cid:<cid> → <uuid>. The caller must already hold c.mu, and must delete
|
||||
// the cids/<cid> key-set member itself (DeleteProperty does both).
|
||||
//
|
||||
// ⚠ Why this exists. A cids/ member write touches THREE keys — the flat
|
||||
// file:<uuid>/cids/<cid>, the __fields__ index entry, and this reverse alias —
|
||||
// while the delete path only ever removed the first two. A "deleted" CID
|
||||
// therefore still resolved: GET /meta/<cid>, ResolveRoot and GetByCID all kept
|
||||
// answering with the record. The asymmetry is not theoretical. meta-share
|
||||
// published dag-pb roots computed over RAR volumes and par2 files onto six
|
||||
// records on watch.nsl.sh; removing the member was expressible, removing the
|
||||
// claim was not, so the false identity survived its own retraction.
|
||||
//
|
||||
// Guarded, not blind: the alias is removed only when it currently points at
|
||||
// THIS uuid. Two records can legitimately race to claim a cid (see the
|
||||
// dual-root guard in addAliasLocked, which leaves the first writer's alias in
|
||||
// place); deleting a member from the loser must not unpick the winner's index.
|
||||
func (c *Client) removeAliasLocked(ctx context.Context, uuid, cidStr string) error {
|
||||
if uuid == "" || cidStr == "" {
|
||||
return nil
|
||||
}
|
||||
indexKey := c.buildCIDIndexKey(cidStr)
|
||||
existing, err := c.client.Get(ctx, indexKey).Result()
|
||||
if err == redis.Nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read existing alias: %w", err)
|
||||
}
|
||||
if existing != uuid {
|
||||
// Somebody else owns this alias. Leave it.
|
||||
return nil
|
||||
}
|
||||
return c.client.Del(ctx, indexKey).Err()
|
||||
}
|
||||
|
||||
// ResolveRoot maps a hash supplied by a caller (a meta-sort write, an editor
|
||||
// lookup, an SSE consumer) to the actual storage root key.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Tests for retracting a `cids/<cid>` key-set member (cid_resolution.go's
|
||||
// removeAliasLocked, reached through DeleteProperty).
|
||||
//
|
||||
// Why this is worth its own file. Adding a member writes THREE keys — the flat
|
||||
// `file:<uuid>/cids/<cid>`, the `__fields__` index entry, and the reverse alias
|
||||
// `cid:<cid>` → `<uuid>`. Until removeAliasLocked existed, deleting removed only
|
||||
// the first two, so a retracted CID kept resolving to the record: the member was
|
||||
// gone from the document and the claim was not.
|
||||
//
|
||||
// That asymmetry had a live consequence. meta-share minted dag-pb roots and
|
||||
// midhash256s over whatever `archive::pick_media` elected, which on an
|
||||
// obfuscated posting was an arbitrary RAR volume or a par2 file. Six records on
|
||||
// watch.nsl.sh carry such members. `METADATA_KEYS.md` §2 defines a `cids/`
|
||||
// member as a digest of *this record's file* and notes that every member is a
|
||||
// reverse index back to the record — so those entries assert that an archive
|
||||
// volume is the episode. Retracting one has to remove the assertion, not just
|
||||
// the row.
|
||||
|
||||
// aliasTarget returns the uuid that cid:<cidStr> currently resolves to, or "".
|
||||
func aliasTarget(t *testing.T, c *Client, cidStr string) string {
|
||||
t.Helper()
|
||||
got, err := c.client.Get(context.Background(), c.buildCIDIndexKey(cidStr)).Result()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func hasField(t *testing.T, c *Client, hashID, field string) bool {
|
||||
t.Helper()
|
||||
ok, err := c.client.SIsMember(context.Background(), c.buildFieldsKey(hashID), field).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("sismember: %v", err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// The real shape: `…glnrqztt…` is the nzb-release locator whose record published
|
||||
// a dag-pb root computed over volume 3 of a 35-volume RAR5 set.
|
||||
const (
|
||||
poisonedRoot = "bafybeicicuzdvuztca2hp2g2k7scgr4nnibilzgoppljeprphybqot7v6i"
|
||||
goodMidhash = "bagacbabaed6uiulnv7tscpxne22eztkjvnxg5almn43dpfecppixarfe7xhlu"
|
||||
)
|
||||
|
||||
func TestRetractingACidMemberRemovesTheClaimNotJustTheRow(t *testing.T) {
|
||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
||||
const uuid = "01KSBHMTESTRECORD"
|
||||
|
||||
if err := c.SetMetadataFlat(uuid, map[string]string{
|
||||
"cids/" + poisonedRoot: "true",
|
||||
"cids/" + goodMidhash: "true",
|
||||
"fileType": "video",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
// All three writes landed, and the CID resolves to the record.
|
||||
if got := aliasTarget(t, c, poisonedRoot); got != uuid {
|
||||
t.Fatalf("alias should point at the record before retraction, got %q", got)
|
||||
}
|
||||
if !hasField(t, c, uuid, "cids/"+poisonedRoot) {
|
||||
t.Fatal("field index should list the member before retraction")
|
||||
}
|
||||
if got := c.ResolveRoot(poisonedRoot); got != uuid {
|
||||
t.Fatalf("ResolveRoot should find the record before retraction, got %q", got)
|
||||
}
|
||||
|
||||
if err := c.DeleteProperty(uuid, "cids/"+poisonedRoot); err != nil {
|
||||
t.Fatalf("DeleteProperty: %v", err)
|
||||
}
|
||||
|
||||
// 1. the flat key
|
||||
if v, err := c.client.Get(context.Background(),
|
||||
c.buildKeyPrefix(uuid)+"cids/"+poisonedRoot).Result(); err == nil {
|
||||
t.Fatalf("flat key should be gone, still %q", v)
|
||||
}
|
||||
// 2. the field index
|
||||
if hasField(t, c, uuid, "cids/"+poisonedRoot) {
|
||||
t.Fatal("field index still lists the retracted member")
|
||||
}
|
||||
// 3. the reverse alias — the one that used to survive
|
||||
if got := aliasTarget(t, c, poisonedRoot); got != "" {
|
||||
t.Fatalf("reverse alias survived retraction, still points at %q", got)
|
||||
}
|
||||
if got := c.ResolveRoot(poisonedRoot); got == uuid {
|
||||
t.Fatal("a retracted cid must not resolve to the record any more")
|
||||
}
|
||||
|
||||
// The siblings are untouched: retraction is surgical, not a purge.
|
||||
if got := aliasTarget(t, c, goodMidhash); got != uuid {
|
||||
t.Fatalf("an unrelated sibling lost its alias, got %q", got)
|
||||
}
|
||||
if !hasField(t, c, uuid, "cids/"+goodMidhash) {
|
||||
t.Fatal("an unrelated sibling lost its field-index entry")
|
||||
}
|
||||
if v, err := c.GetProperty(uuid, "fileType"); err != nil || v != "video" {
|
||||
t.Fatalf("an unrelated field changed: %q %v", v, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Two records can race to claim one cid; addAliasLocked leaves the first
|
||||
// writer's alias in place. Retracting the loser's member must not unpick the
|
||||
// winner's index — which is why removeAliasLocked compares before deleting.
|
||||
func TestRetractingDoesNotStealAnotherRecordsAlias(t *testing.T) {
|
||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
||||
const winner = "01KSBHMWINNER"
|
||||
const loser = "01KSBHMLOSER"
|
||||
const shared = "bafybeiaeb2lqi4z3np3q4tarldtyjp2t5lvnoodmtpvpi2tz73azn5td2m"
|
||||
|
||||
if err := c.SetMetadataFlat(winner, map[string]string{"cids/" + shared: "true"}); err != nil {
|
||||
t.Fatalf("seed winner: %v", err)
|
||||
}
|
||||
if err := c.SetMetadataFlat(loser, map[string]string{"cids/" + shared: "true"}); err != nil {
|
||||
t.Fatalf("seed loser: %v", err)
|
||||
}
|
||||
if got := aliasTarget(t, c, shared); got != winner {
|
||||
t.Fatalf("the first writer should own the alias, got %q", got)
|
||||
}
|
||||
|
||||
if err := c.DeleteProperty(loser, "cids/"+shared); err != nil {
|
||||
t.Fatalf("DeleteProperty: %v", err)
|
||||
}
|
||||
|
||||
if got := aliasTarget(t, c, shared); got != winner {
|
||||
t.Fatalf("retracting the loser's member stole the winner's alias, now %q", got)
|
||||
}
|
||||
if hasField(t, c, loser, "cids/"+shared) {
|
||||
t.Fatal("the loser's own member should still be gone")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A non-cids field has no alias to clean up, and the delete must not invent one.
|
||||
func TestRetractingAnOrdinaryFieldTouchesNoAlias(t *testing.T) {
|
||||
forEachPrefix(t, func(t *testing.T, c *Client) {
|
||||
const uuid = "01KSBHMORDINARY"
|
||||
if err := c.SetMetadataFlat(uuid, map[string]string{
|
||||
"cids/" + goodMidhash: "true",
|
||||
"quality": "1080p",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := c.DeleteProperty(uuid, "quality"); err != nil {
|
||||
t.Fatalf("DeleteProperty: %v", err)
|
||||
}
|
||||
if got := aliasTarget(t, c, goodMidhash); got != uuid {
|
||||
t.Fatalf("deleting an ordinary field disturbed a cid alias, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -460,6 +460,10 @@ func (c *Client) MergeMetadataFlat(hashID string, metadata map[string]string) (i
|
||||
|
||||
// DeleteProperty deletes a single property
|
||||
// Uses flat key: DEL file:{hashId}/{property}
|
||||
//
|
||||
// For a cids/<cid> member this is the full inverse of the write: the flat key,
|
||||
// the __fields__ entry AND the cid:<cid> reverse alias all go. Anything less
|
||||
// leaves the CID still resolving to this record — see removeAliasLocked.
|
||||
func (c *Client) DeleteProperty(hashID, property string) error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -475,6 +479,13 @@ func (c *Client) DeleteProperty(hashID, property string) error {
|
||||
if err := c.client.Del(ctx, key).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Before the field index forgets the name, use it: a cids/ member owns a
|
||||
// reverse-index entry that nothing else will ever clean up.
|
||||
if cidStr := cidFromKeysetField(property); cidStr != "" {
|
||||
if err := c.removeAliasLocked(ctx, hashID, cidStr); err != nil {
|
||||
return fmt.Errorf("remove alias for %s: %w", cidStr, err)
|
||||
}
|
||||
}
|
||||
c.removeFieldLocked(ctx, hashID, property)
|
||||
c.markDirtyLocked(ctx, hashID)
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CountRoots returns the number of metadata roots in the file index.
|
||||
//
|
||||
// SCARD, not SMEMBERS: GetAllHashIDs ships every member over the wire (~3MB
|
||||
// and rising on an 88k-record box) just so the caller can take len(). A
|
||||
// dashboard tile that polls needs the number, never the list.
|
||||
func (c *Client) CountRoots() (int64, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return 0, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
n, err := c.client.SCard(ctx, c.buildIndexKey()).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scard failed: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CountKeys returns DBSIZE — every key in the Redis database, not just the
|
||||
// ones meta-core owns. It is the denominator for "how much of Redis is
|
||||
// records": ~3.1M keys against ~89k roots means the flat per-field keys
|
||||
// (file:{root}/{field}) dominate, which is expected.
|
||||
func (c *Client) CountKeys() (int64, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return 0, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
n, err := c.client.DBSize(ctx).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("dbsize failed: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
Reference in New Issue
Block a user