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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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`. |
|
| **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. |
|
| **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. |
|
||||||
@@ -167,12 +167,43 @@ GET /health # storage + role
|
|||||||
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 intentionally empty)
|
||||||
GET /services # registered services + heartbeats
|
GET /neighbors # neighbours heard over UDP (meta-discovery v1)
|
||||||
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 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
|
### 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
|
||||||
|
|||||||
Vendored
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
</style>
|
</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">
|
<link rel="stylesheet" crossorigin href="/assets/index-DtR_2V4s.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 { 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="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}>
|
<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}>
|
||||||
|
Redis: {health.redis ? 'Connected' : 'Disconnected'}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -276,9 +276,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
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ type Server struct {
|
|||||||
mesh *meshdisco.Node
|
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 Redis walk on the request path.
|
||||||
|
stats *statsCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMeshNode attaches the UDP discovery node. Separate from NewServer so the
|
// SetMeshNode attaches the UDP discovery node. Separate from NewServer so the
|
||||||
@@ -70,6 +73,7 @@ func NewServer(
|
|||||||
storage: stor,
|
storage: stor,
|
||||||
search: &searchIndex{},
|
search: &searchIndex{},
|
||||||
router: mux.NewRouter(),
|
router: mux.NewRouter(),
|
||||||
|
stats: &statsCache{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize file watcher/scanner and dispatcher
|
// 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("/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")
|
||||||
|
|||||||
@@ -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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeAliasLocked is the inverse of addAliasLocked: it drops the reverse-index
|
||||||
|
// entry cid:<cid> → <uuid>. The caller must already hold c.mu, and must delete
|
||||||
|
// the cids/<cid> key-set member itself (DeleteProperty does both).
|
||||||
|
//
|
||||||
|
// ⚠ Why this exists. A cids/ member write touches THREE keys — the flat
|
||||||
|
// file:<uuid>/cids/<cid>, the __fields__ index entry, and this reverse alias —
|
||||||
|
// while the delete path only ever removed the first two. A "deleted" CID
|
||||||
|
// therefore still resolved: GET /meta/<cid>, ResolveRoot and GetByCID all kept
|
||||||
|
// answering with the record. The asymmetry is not theoretical. meta-share
|
||||||
|
// published dag-pb roots computed over RAR volumes and par2 files onto six
|
||||||
|
// records on watch.nsl.sh; removing the member was expressible, removing the
|
||||||
|
// claim was not, so the false identity survived its own retraction.
|
||||||
|
//
|
||||||
|
// Guarded, not blind: the alias is removed only when it currently points at
|
||||||
|
// THIS uuid. Two records can legitimately race to claim a cid (see the
|
||||||
|
// dual-root guard in addAliasLocked, which leaves the first writer's alias in
|
||||||
|
// place); deleting a member from the loser must not unpick the winner's index.
|
||||||
|
func (c *Client) removeAliasLocked(ctx context.Context, uuid, cidStr string) error {
|
||||||
|
if uuid == "" || cidStr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
indexKey := c.buildCIDIndexKey(cidStr)
|
||||||
|
existing, err := c.client.Get(ctx, indexKey).Result()
|
||||||
|
if err == redis.Nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read existing alias: %w", err)
|
||||||
|
}
|
||||||
|
if existing != uuid {
|
||||||
|
// Somebody else owns this alias. Leave it.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.client.Del(ctx, indexKey).Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveRoot maps a hash supplied by a caller (a meta-sort write, an editor
|
// ResolveRoot maps a hash supplied by a caller (a meta-sort write, an editor
|
||||||
// lookup, an SSE consumer) to the actual storage root key.
|
// lookup, an SSE consumer) to the actual storage root key.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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
|
// DeleteProperty deletes a single property
|
||||||
// Uses flat key: DEL file:{hashId}/{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 {
|
func (c *Client) DeleteProperty(hashID, property string) error {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
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 {
|
if err := c.client.Del(ctx, key).Err(); err != nil {
|
||||||
return err
|
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.removeFieldLocked(ctx, hashID, property)
|
||||||
c.markDirtyLocked(ctx, hashID)
|
c.markDirtyLocked(ctx, hashID)
|
||||||
return nil
|
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