v2.0.0: claims model - signed MMUR2 votes in a truth DB, merged view in a rebuildable cache DB; POST /api/claims, claims and merge routes, unsigned writes answer 410
Build and Push Docker Image / build (push) Successful in 5m1s
Build and Push Docker Image / build (push) Successful in 5m1s
This commit is contained in:
@@ -3,34 +3,22 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/metazla/meta-core/internal/cid"
|
||||
"github.com/metazla/meta-core/internal/kv"
|
||||
)
|
||||
|
||||
// This file implements the CID-resolution layer that lives on top of the
|
||||
// flat metadata storage in client.go. The short version:
|
||||
// The cid → record resolution layer, read side. A record (rid) is a group of
|
||||
// cids known to be the same content; the group lives in truth's cid_group and
|
||||
// is mirrored into the cache as
|
||||
//
|
||||
// - Roots are opaque UUIDv7s — file:<uuid>/<property> for every property.
|
||||
// The UUID is an internal implementation detail; the mental model is
|
||||
// "N CIDs → one record of keys→metadata".
|
||||
// - Every known CID for a file is registered as a reverse-index entry:
|
||||
// cid:<bareCid> → <uuid>
|
||||
// and as a member of the per-root bare-CID key-set:
|
||||
// file:<uuid>/cids/<bareCid> = "true" (flat key, sentinel value)
|
||||
// - A CID is self-describing: its algorithm is recoverable from the CIDv1
|
||||
// multicodec (see internal/cid), so there is no per-algorithm field name
|
||||
// and no <algo>: token prefix. The canonical externally-visible CID is
|
||||
// *derived by rank* from the key-set on read (GetMetadataDocument), never
|
||||
// stored.
|
||||
// cid:<bareCid> → <rid>
|
||||
// file:<rid>/cids/<bareCid> = "true" (synthesised membership, one per cid)
|
||||
//
|
||||
// The record write path (writeFieldsTx) catches any write of a cids/<cid>
|
||||
// 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
|
||||
// reverse index at all. The helpers here take the caller's *kv.Tx: a write
|
||||
// must never open a second transaction (there is one writer connection).
|
||||
// by the apply loop (apply.go). A CID is self-describing (its algorithm is in
|
||||
// the CIDv1 multicodec, see internal/cid); the canonical externally-visible
|
||||
// CID is derived by rank on read (GetMetadataDocument), never stored.
|
||||
|
||||
// CIDIndexPrefix is the key prefix for the reverse index. Combined
|
||||
// with a bare CID, it forms the full key: "cid:bafk…".
|
||||
@@ -43,114 +31,6 @@ const CIDIndexPrefix = "cid:"
|
||||
// reverse-index entries that need cleanup).
|
||||
const CIDsKeyPrefix = "cids/"
|
||||
|
||||
// Duplicate paths — other physical locations whose content hashes to the same
|
||||
// CIDs as a root — live as dup:<rid>/<path> = "" (a key-set). The watcher
|
||||
// writes one when it sees a new path for content that's already indexed.
|
||||
|
||||
// Mint creates a new root with filePath/sizeByte/mtimeNano and registers it
|
||||
// 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
|
||||
// reachable only via the index, which works but defeats the point.
|
||||
func (c *Client) Mint(filePath string, size, mtimeNano int64) (string, error) {
|
||||
var uuid string
|
||||
err := c.update(func(tx *kv.Tx) error {
|
||||
var err error
|
||||
uuid, err = mintTx(tx, filePath, size, mtimeNano)
|
||||
return err
|
||||
})
|
||||
return uuid, err
|
||||
}
|
||||
|
||||
func mintTx(tx *kv.Tx, filePath string, size, mtimeNano int64) (string, error) {
|
||||
uuid, err := NewUUID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("uuid: %w", err)
|
||||
}
|
||||
return uuid, writeFieldsTx(tx, uuid, map[string]string{
|
||||
"filePath": filePath,
|
||||
"sizeByte": strconv.FormatInt(size, 10),
|
||||
"mtimeNano": strconv.FormatInt(mtimeNano, 10),
|
||||
})
|
||||
}
|
||||
|
||||
// AddAlias registers cidStr as a sibling CID of uuid: it writes the reverse
|
||||
// index cid:<cidStr> → uuid and the key-set member cids/<cidStr> = "true".
|
||||
// Idempotent: registering the same CID twice is a no-op.
|
||||
func (c *Client) AddAlias(uuid, cidStr string) error {
|
||||
if uuid == "" || cidStr == "" {
|
||||
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"})
|
||||
})
|
||||
}
|
||||
|
||||
// addAliasTx registers the reverse-index entry for a cids/<cid> member the
|
||||
// caller is writing (writeFieldsTx writes the member itself).
|
||||
func addAliasTx(tx *kv.Tx, uuid, cidStr string) error {
|
||||
if uuid == "" || cidStr == "" {
|
||||
return fmt.Errorf("uuid and cid are required")
|
||||
}
|
||||
// Self-pointing alias guard. If the caller is trying to register
|
||||
// cid:<cid> → <cid> (i.e. the root IS the cid value), that's the
|
||||
// dual-root failure mode in disguise — it means somebody wrote
|
||||
// /meta/<cid> without first resolving via ResolveRoot. Refuse the alias
|
||||
// write; leave any existing alias alone. The key-set membership is still
|
||||
// recorded by the caller, so the field-level write succeeds.
|
||||
if uuid == cidStr {
|
||||
return nil
|
||||
}
|
||||
// Guard against the dual-root pattern: if cid:<cid> already points at a
|
||||
// DIFFERENT uuid, don't silently overwrite. Leave the existing alias; the
|
||||
// key-set membership keeps the CID discoverable via root → cids.
|
||||
indexKey := CIDIndexPrefix + cidStr
|
||||
existing, _, err := tx.Get(tx.Context(), indexKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read existing alias: %w", err)
|
||||
}
|
||||
if existing != "" && existing != uuid {
|
||||
return nil
|
||||
}
|
||||
if existing == uuid {
|
||||
return nil
|
||||
}
|
||||
return tx.Put(indexKey, uuid)
|
||||
}
|
||||
|
||||
// removeAliasTx is the inverse of addAliasTx: it drops the reverse-index
|
||||
// entry cid:<cid> → <uuid>. The caller deletes the cids/<cid> key-set member
|
||||
// itself (DeleteProperty does both).
|
||||
//
|
||||
// ⚠ 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
|
||||
}
|
||||
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
|
||||
// lookup, an SSE consumer) to the actual storage root key.
|
||||
//
|
||||
@@ -202,23 +82,6 @@ func (c *Client) GetByCID(cidStr string) (string, error) {
|
||||
return uuid, nil
|
||||
}
|
||||
|
||||
// AddDuplicatePath records that path is another physical location whose
|
||||
// content matches uuid. Returns true if path was newly added. The watcher
|
||||
// calls this when it sees a file whose midhash matches an existing root but
|
||||
// at a different filePath.
|
||||
func (c *Client) AddDuplicatePath(uuid, path string) (bool, error) {
|
||||
if uuid == "" || path == "" {
|
||||
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
|
||||
}
|
||||
|
||||
// cidsForRoot returns a root's bare-CID key-set members (cids/ prefix
|
||||
// stripped).
|
||||
func cidsForRoot(ctx context.Context, r kv.Reader, uuid string) ([]string, error) {
|
||||
@@ -234,20 +97,6 @@ func cidsForRoot(ctx context.Context, r kv.Reader, uuid string) ([]string, error
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteRoot removes everything keyed off uuid: every file:<uuid>/* property
|
||||
// (including the cids/<cid> key-set), every reverse-index entry for those
|
||||
// CIDs, its duplicates, search row and index membership. Idempotent — a uuid
|
||||
// with no remaining keys is a successful no-op.
|
||||
func (c *Client) DeleteRoot(uuid string) error {
|
||||
if uuid == "" {
|
||||
return nil
|
||||
}
|
||||
return c.update(func(tx *kv.Tx) error {
|
||||
_, err := deleteRecordTx(tx, uuid)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// cidFromKeysetField returns the bare CID encoded in a cids/<cid> field name,
|
||||
// or "" if the field isn't a key-set member.
|
||||
func cidFromKeysetField(field string) string {
|
||||
@@ -311,13 +160,16 @@ func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
|
||||
}
|
||||
}
|
||||
|
||||
dupRows, err := s.Range(ctx, dupPrefix(uuid), "", 0)
|
||||
// Duplicates: every other local path holding the same content.
|
||||
paths, err := localPaths(ctx, s.DB(), cids)
|
||||
if err != nil {
|
||||
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)):])
|
||||
dups := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
if p != flat["filePath"] {
|
||||
dups = append(dups, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(flat) == 0 && len(cids) == 0 && len(dups) == 0 {
|
||||
@@ -330,66 +182,3 @@ func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
|
||||
Duplicates: dups,
|
||||
}, 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user