replace Redis with an embedded SQLite string->string KV store (group commit, in-tx events, SQL search)
Build and Push Docker Image / build (push) Successful in 2m44s
Build and Push Docker Image / build (push) Successful in 2m44s
This commit is contained in:
+238
-277
@@ -12,28 +12,31 @@ package storage
|
||||
// strictly exceeds the stored one (a per-cell high-water-mark, replacing the
|
||||
// op-log merge that used to live in meta-watch's store.rs).
|
||||
//
|
||||
// Redis model (all under the literal "udl:" prefix, mirroring the "file:" one):
|
||||
// Key model (all under the literal "udl:" prefix, mirroring the "file:" one):
|
||||
//
|
||||
// udl:rec:<uid>/<cid>/<key> HASH { record, version, ts, value? }
|
||||
// udl:idx:user:<uid>:key:<key> SET of <cid> -> active_for_user_key (My List, Continue Watching)
|
||||
// udl:idx:user:<uid>:cid:<cid> SET of <key> -> list_for_user_cid
|
||||
// udl:idx:user:<uid>:cids SET of <cid> -> whole-profile export (recovery)
|
||||
// udl:idx:cid:<cid>:key:<key> SET of <uid> -> aggregate
|
||||
// udl:idx:cid:<cid>:users SET of <uid> -> uids_for_cid
|
||||
// udl:rec:<uid>/<cid>/<key> JSON { record, version, ts, value? }
|
||||
// udl:idx:user:<uid>:key:<key> set of <cid> -> active_for_user_key (My List, Continue Watching)
|
||||
// udl:idx:user:<uid>:cid:<cid> set of <key> -> list_for_user_cid
|
||||
// udl:idx:user:<uid>:cids set of <cid> -> whole-profile export (recovery)
|
||||
// udl:idx:cid:<cid>:key:<key> set of <uid> -> aggregate
|
||||
// udl:idx:cid:<cid>:users set of <uid> -> uids_for_cid
|
||||
//
|
||||
// The `value` hash field is the plaintext JSON scalar for PUBLIC keys only
|
||||
// A "set" is a key-set: one key `<set>\x1f<member>` = "" per member, read as
|
||||
// the key range under `<set>\x1f`. The separator is a control character
|
||||
// because UDL keys are not validated — with `/` the members of
|
||||
// `…:key:a/b` would show up as members of `…:key:a`.
|
||||
//
|
||||
// The `value` field is the plaintext JSON scalar for PUBLIC keys only
|
||||
// (like/rating), used for aggregation; it is absent for private-tier keys
|
||||
// (whose real value rides encrypted inside `record`) and for tombstones.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/metazla/meta-core/internal/kv"
|
||||
)
|
||||
|
||||
// UDLCell is a decoded (uid, cid, key) record cell.
|
||||
@@ -67,7 +70,7 @@ func udlRecKey(uid, cid, key string) string {
|
||||
func udlIdxUserKey(uid, key string) string { return fmt.Sprintf("udl:idx:user:%s:key:%s", uid, key) }
|
||||
func udlIdxUserCid(uid, cid string) string { return fmt.Sprintf("udl:idx:user:%s:cid:%s", uid, cid) }
|
||||
func udlIdxCidKey(cid, key string) string { return fmt.Sprintf("udl:idx:cid:%s:key:%s", cid, key) }
|
||||
func udlIdxCidUsers(cid string) string { return fmt.Sprintf("udl:idx:cid:%s:users", cid) }
|
||||
func udlIdxCidUsers(cid string) string { return fmt.Sprintf("udl:idx:cid:%s:users", cid) }
|
||||
|
||||
// udlIdxUserCids is the set of every cid a uid has ANY record for — the one
|
||||
// index that makes "export this whole profile" a set read instead of a
|
||||
@@ -81,161 +84,196 @@ func udlIdxCidUsers(cid string) string { return fmt.Sprintf("udl:idx:cid:%s
|
||||
// already made `/api/udl/users/stats` admin-only.
|
||||
func udlIdxUserCids(uid string) string { return fmt.Sprintf("udl:idx:user:%s:cids", uid) }
|
||||
|
||||
// udlUpsertScript performs the version-gated upsert + index maintenance
|
||||
// atomically. Accepts (returns 1) only when the incoming version strictly
|
||||
// exceeds the stored one; otherwise returns 0 (stale).
|
||||
//
|
||||
// KEYS: 1=cell 2=idx:user:key 3=idx:user:cid 4=idx:cid:key 5=idx:cid:users
|
||||
// 6=idx:user:cids
|
||||
// ARGV: 1=version 2=ts 3=record 4=value 5=hasValue("1"/"0")
|
||||
// 6=cid 7=key 8=uid 9=tombstone("1"/"0")
|
||||
//
|
||||
// A tombstone still writes the cell — that is the CRDT state, and dropping it
|
||||
// would let an older write resurrect the value — but it SREMs the cid from the
|
||||
// user+key index. That index is what `active_for_user_key` returns, so leaving
|
||||
// deleted cids in it means every My List / Continue Watching read carries every
|
||||
// title ever un-liked or finished, forever. The other four indexes are left
|
||||
// alone: they answer "who has an opinion about this cid", where a tombstone is
|
||||
// still an answer.
|
||||
//
|
||||
// ⚠ `idx:user:cids` (KEYS[6]) in particular must NEVER be pruned on a
|
||||
// tombstone, even though it looks symmetric with KEYS[2]. It is the export
|
||||
// index: a cid dropped from it is a cid whose tombstone never travels, and a
|
||||
// tombstone that does not travel is exactly how an un-liked title comes back
|
||||
// from the dead on the other box — the peer still holds the older `like` at a
|
||||
// lower version, and with no tombstone to beat it, nothing ever supersedes it.
|
||||
var udlUpsertScript = redis.NewScript(`
|
||||
local stored = redis.call('HGET', KEYS[1], 'version')
|
||||
if stored and tonumber(stored) >= tonumber(ARGV[1]) then
|
||||
return 0
|
||||
end
|
||||
redis.call('HSET', KEYS[1], 'version', ARGV[1], 'ts', ARGV[2], 'record', ARGV[3])
|
||||
if ARGV[5] == '1' then
|
||||
redis.call('HSET', KEYS[1], 'value', ARGV[4])
|
||||
else
|
||||
redis.call('HDEL', KEYS[1], 'value')
|
||||
end
|
||||
if ARGV[9] == '1' then
|
||||
redis.call('SREM', KEYS[2], ARGV[6])
|
||||
else
|
||||
redis.call('SADD', KEYS[2], ARGV[6])
|
||||
end
|
||||
redis.call('SADD', KEYS[3], ARGV[7])
|
||||
redis.call('SADD', KEYS[4], ARGV[8])
|
||||
redis.call('SADD', KEYS[5], ARGV[8])
|
||||
redis.call('SADD', KEYS[6], ARGV[6])
|
||||
return 1
|
||||
`)
|
||||
// udlSetSep separates a set key from a member (see the key model above).
|
||||
const udlSetSep = "\x1f"
|
||||
|
||||
// udlCellJSON is a cell's stored form. Value is a pointer so an absent value
|
||||
// (private-tier key, tombstone) stays distinguishable from an empty one.
|
||||
type udlCellJSON struct {
|
||||
Record string `json:"record"`
|
||||
Version int64 `json:"version"`
|
||||
Ts int64 `json:"ts"`
|
||||
Value *string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
func decodeUDLCell(raw string) (UDLCell, error) {
|
||||
var j udlCellJSON
|
||||
if err := json.Unmarshal([]byte(raw), &j); err != nil {
|
||||
return UDLCell{}, err
|
||||
}
|
||||
cell := UDLCell{Record: j.Record, Version: j.Version, Ts: j.Ts}
|
||||
if j.Value != nil {
|
||||
cell.Value, cell.HasValue = *j.Value, true
|
||||
}
|
||||
return cell, nil
|
||||
}
|
||||
|
||||
// udlMembers lists a key-set's members, sorted.
|
||||
func udlMembers(ctx context.Context, r kv.Reader, set string) ([]string, error) {
|
||||
prefix := set + udlSetSep
|
||||
rows, err := r.Range(ctx, prefix, "", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row.K[len(prefix):])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func udlAdd(tx *kv.Tx, set, member string) error {
|
||||
_, err := tx.PutIfAbsent(set+udlSetSep+member, "")
|
||||
return err
|
||||
}
|
||||
|
||||
func udlRem(tx *kv.Tx, set, member string) error {
|
||||
_, err := tx.Delete(set + udlSetSep + member)
|
||||
return err
|
||||
}
|
||||
|
||||
// readUDLCell reads one cell. exists is false when it was never written.
|
||||
func readUDLCell(ctx context.Context, r kv.Reader, recKey string) (UDLCell, bool, error) {
|
||||
raw, ok, err := r.Get(ctx, recKey)
|
||||
if err != nil || !ok {
|
||||
return UDLCell{}, false, err
|
||||
}
|
||||
cell, err := decodeUDLCell(raw)
|
||||
if err != nil {
|
||||
return UDLCell{}, false, fmt.Errorf("decode %s: %w", recKey, err)
|
||||
}
|
||||
return cell, true, nil
|
||||
}
|
||||
|
||||
// readUDLCells reads many cells in one multi-key read; absent cells are
|
||||
// omitted from the map.
|
||||
func readUDLCells(ctx context.Context, r kv.Reader, recKeys []string) (map[string]UDLCell, error) {
|
||||
raws, err := r.MGet(ctx, recKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]UDLCell, len(raws))
|
||||
for k, raw := range raws {
|
||||
cell, err := decodeUDLCell(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode %s: %w", k, err)
|
||||
}
|
||||
out[k] = cell
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UDLUpsertIfNewer stores a record cell iff version strictly exceeds the stored
|
||||
// one. publicValue/hasValue carry the plaintext JSON scalar for public keys
|
||||
// (used by aggregation); pass hasValue=false for private-tier keys and
|
||||
// tombstones. Set tombstone=true when the record deletes the cell, which also
|
||||
// prunes the cid from the user+key index. Returns accepted=false when the write
|
||||
// is stale.
|
||||
// one, and maintains the indexes — one transaction, so the gate and the write
|
||||
// cannot interleave with a concurrent upsert of the same cell.
|
||||
// publicValue/hasValue carry the plaintext JSON scalar for public keys (used by
|
||||
// aggregation); pass hasValue=false for private-tier keys and tombstones. Set
|
||||
// tombstone=true when the record deletes the cell.
|
||||
//
|
||||
// A tombstone still writes the cell — that is the CRDT state, and dropping it
|
||||
// would let an older write resurrect the value — but it removes the cid from
|
||||
// the user+key index. That index is what `active_for_user_key` returns, so
|
||||
// leaving deleted cids in it means every My List / Continue Watching read
|
||||
// carries every title ever un-liked or finished, forever. The other four
|
||||
// indexes are left alone: they answer "who has an opinion about this cid",
|
||||
// where a tombstone is still an answer.
|
||||
//
|
||||
// ⚠ `idx:user:cids` in particular must NEVER be pruned on a tombstone, even
|
||||
// though it looks symmetric with the user+key index. It is the export index: a
|
||||
// cid dropped from it is a cid whose tombstone never travels, and a tombstone
|
||||
// that does not travel is exactly how an un-liked title comes back from the
|
||||
// dead on the other box — the peer still holds the older `like` at a lower
|
||||
// version, and with no tombstone to beat it, nothing ever supersedes it.
|
||||
//
|
||||
// Returns accepted=false when the write is stale.
|
||||
func (c *Client) UDLUpsertIfNewer(uid, cid, key string, version, ts int64, record, publicValue string, hasValue, tombstone bool) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client == nil {
|
||||
return false, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
keys := []string{
|
||||
c.buildKey(udlRecKey(uid, cid, key)),
|
||||
c.buildKey(udlIdxUserKey(uid, key)),
|
||||
c.buildKey(udlIdxUserCid(uid, cid)),
|
||||
c.buildKey(udlIdxCidKey(cid, key)),
|
||||
c.buildKey(udlIdxCidUsers(cid)),
|
||||
c.buildKey(udlIdxUserCids(uid)),
|
||||
}
|
||||
hv := "0"
|
||||
if hasValue {
|
||||
hv = "1"
|
||||
}
|
||||
tomb := "0"
|
||||
if tombstone {
|
||||
tomb = "1"
|
||||
}
|
||||
res, err := udlUpsertScript.Run(ctx, c.client, keys, version, ts, record, publicValue, hv, cid, key, uid, tomb).Result()
|
||||
accepted := false
|
||||
err := c.update(func(tx *kv.Tx) error {
|
||||
recKey := udlRecKey(uid, cid, key)
|
||||
stored, ok, err := readUDLCell(tx.Context(), tx, recKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok && stored.Version >= version {
|
||||
return nil
|
||||
}
|
||||
cell := udlCellJSON{Record: record, Version: version, Ts: ts}
|
||||
if hasValue {
|
||||
v := publicValue
|
||||
cell.Value = &v
|
||||
}
|
||||
body, err := json.Marshal(cell)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Put(recKey, string(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
if tombstone {
|
||||
err = udlRem(tx, udlIdxUserKey(uid, key), cid)
|
||||
} else {
|
||||
err = udlAdd(tx, udlIdxUserKey(uid, key), cid)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, add := range [][2]string{
|
||||
{udlIdxUserCid(uid, cid), key},
|
||||
{udlIdxCidKey(cid, key), uid},
|
||||
{udlIdxCidUsers(cid), uid},
|
||||
{udlIdxUserCids(uid), cid},
|
||||
} {
|
||||
if err := udlAdd(tx, add[0], add[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
accepted = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("udl upsert failed: %w", err)
|
||||
}
|
||||
n, _ := res.(int64)
|
||||
return n == 1, nil
|
||||
return accepted, nil
|
||||
}
|
||||
|
||||
// UDLGetCell returns the record cell for (uid, cid, key). exists is false when
|
||||
// no record has ever been written for the cell.
|
||||
func (c *Client) UDLGetCell(uid, cid, key string) (UDLCell, bool, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return UDLCell{}, false, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return c.udlReadCell(ctx, udlRecKey(uid, cid, key))
|
||||
}
|
||||
|
||||
// udlReadCell HMGETs a cell hash. Caller holds the lock. exists is false when
|
||||
// the version field is absent (i.e. the hash does not exist).
|
||||
func (c *Client) udlReadCell(ctx context.Context, recKey string) (UDLCell, bool, error) {
|
||||
vals, err := c.client.HMGet(ctx, c.buildKey(recKey), "record", "version", "ts", "value").Result()
|
||||
s, err := c.kv()
|
||||
if err != nil {
|
||||
return UDLCell{}, false, fmt.Errorf("hmget failed: %w", err)
|
||||
return UDLCell{}, false, err
|
||||
}
|
||||
// vals: [record, version, ts, value] — nil for absent fields.
|
||||
if len(vals) < 4 || vals[1] == nil {
|
||||
return UDLCell{}, false, nil
|
||||
}
|
||||
cell := UDLCell{}
|
||||
if s, ok := vals[0].(string); ok {
|
||||
cell.Record = s
|
||||
}
|
||||
if s, ok := vals[1].(string); ok {
|
||||
cell.Version, _ = strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
if s, ok := vals[2].(string); ok {
|
||||
cell.Ts, _ = strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
if s, ok := vals[3].(string); ok {
|
||||
cell.Value = s
|
||||
cell.HasValue = true
|
||||
}
|
||||
return cell, true, nil
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
return readUDLCell(ctx, s, udlRecKey(uid, cid, key))
|
||||
}
|
||||
|
||||
// UDLListUserKey returns every cell for (uid, key) across all cids — the
|
||||
// projection behind active_for_user_key (My List, Continue Watching). Includes
|
||||
// tombstones; the caller decodes each record and filters.
|
||||
func (c *Client) UDLListUserKey(uid, key string) ([]UDLUserKeyEntry, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
s, err := c.kv()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
|
||||
cids, err := c.client.SMembers(ctx, c.buildKey(udlIdxUserKey(uid, key))).Result()
|
||||
cids, err := udlMembers(ctx, s, udlIdxUserKey(uid, key))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smembers failed: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
recKeys := make([]string, len(cids))
|
||||
for i, cid := range cids {
|
||||
recKeys[i] = udlRecKey(uid, cid, key)
|
||||
}
|
||||
cells, err := readUDLCells(ctx, s, recKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]UDLUserKeyEntry, 0, len(cids))
|
||||
for _, cid := range cids {
|
||||
cell, ok, err := c.udlReadCell(ctx, udlRecKey(uid, cid, key))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, cid := range cids {
|
||||
cell, ok := cells[recKeys[i]]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -247,26 +285,28 @@ func (c *Client) UDLListUserKey(uid, key string) ([]UDLUserKeyEntry, error) {
|
||||
// UDLListUserCid returns every cell for (uid, cid) across all keys — the
|
||||
// projection behind list_for_user_cid.
|
||||
func (c *Client) UDLListUserCid(uid, cid string) ([]UDLUserCidEntry, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
s, err := c.kv()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
|
||||
keys, err := c.client.SMembers(ctx, c.buildKey(udlIdxUserCid(uid, cid))).Result()
|
||||
keys, err := udlMembers(ctx, s, udlIdxUserCid(uid, cid))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smembers failed: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
recKeys := make([]string, len(keys))
|
||||
for i, key := range keys {
|
||||
recKeys[i] = udlRecKey(uid, cid, key)
|
||||
}
|
||||
cells, err := readUDLCells(ctx, s, recKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]UDLUserCidEntry, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
cell, ok, err := c.udlReadCell(ctx, udlRecKey(uid, cid, key))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, key := range keys {
|
||||
cell, ok := cells[recKeys[i]]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -286,33 +326,28 @@ type UDLUserAllEntry struct {
|
||||
|
||||
// UDLAllForUser returns every cell a uid owns, for cross-instance recovery.
|
||||
//
|
||||
// Ordered by cid (sorted, so paging is stable across calls even though the
|
||||
// underlying index is a Redis SET), starting strictly after `afterCid`. A cid's
|
||||
// keys are never split across a page: the page ends on a cid boundary once
|
||||
// `limit` rows have been emitted, and `nextCid` is the last cid included — pass
|
||||
// it back as `afterCid` to continue. `nextCid` is empty when the export is
|
||||
// complete.
|
||||
// Ordered by cid, starting strictly after `afterCid`. A cid's keys are never
|
||||
// split across a page: the page ends on a cid boundary once `limit` rows have
|
||||
// been emitted, and `nextCid` is the last cid included — pass it back as
|
||||
// `afterCid` to continue. `nextCid` is empty when the export is complete.
|
||||
//
|
||||
// Tombstones are included. They are the CRDT state that stops a deleted value
|
||||
// resurrecting on the box that receives this export, so omitting them would
|
||||
// make recovery actively wrong rather than merely incomplete.
|
||||
func (c *Client) UDLAllForUser(uid, afterCid string, limit int) ([]UDLUserAllEntry, string, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return nil, "", fmt.Errorf("not connected")
|
||||
s, err := c.kv()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
|
||||
cids, err := c.client.SMembers(ctx, c.buildKey(udlIdxUserCids(uid))).Result()
|
||||
cids, err := udlMembers(ctx, s, udlIdxUserCids(uid))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("smembers failed: %w", err)
|
||||
return nil, "", err
|
||||
}
|
||||
sort.Strings(cids)
|
||||
|
||||
@@ -326,13 +361,12 @@ func (c *Client) UDLAllForUser(uid, afterCid string, limit int) ([]UDLUserAllEnt
|
||||
nextCid = out[len(out)-1].Cid
|
||||
break
|
||||
}
|
||||
keys, err := c.client.SMembers(ctx, c.buildKey(udlIdxUserCid(uid, cid))).Result()
|
||||
keys, err := udlMembers(ctx, s, udlIdxUserCid(uid, cid))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("smembers failed: %w", err)
|
||||
return nil, "", err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
cell, ok, err := c.udlReadCell(ctx, udlRecKey(uid, cid, key))
|
||||
cell, ok, err := readUDLCell(ctx, s, udlRecKey(uid, cid, key))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -349,133 +383,60 @@ func (c *Client) UDLAllForUser(uid, afterCid string, limit int) ([]UDLUserAllEnt
|
||||
|
||||
// UDLCountUserCids is the number of distinct cids a uid holds records for.
|
||||
//
|
||||
// O(1) (a Redis SCARD), which is what makes it usable as a per-push quota
|
||||
// One index range count, which is what makes it usable as a per-push quota
|
||||
// check on the gateway mirror — a quota that cost a keyspace walk would be a
|
||||
// denial-of-service in its own right. Counts cids rather than cells because
|
||||
// that is what the index holds; a profile is bounded by the titles it has
|
||||
// touched, not by how many keys it has per title.
|
||||
func (c *Client) UDLCountUserCids(uid string) (int64, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return 0, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
n, err := c.client.SCard(ctx, c.buildKey(udlIdxUserCids(uid))).Result()
|
||||
s, err := c.kv()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scard failed: %w", err)
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// UDLBackfillUserCids rebuilds the `idx:user:<uid>:cids` sets for data written
|
||||
// before that index existed, and reports how many cids it added.
|
||||
//
|
||||
// One-shot: the upsert script maintains the set from now on. It is a SCAN, but
|
||||
// a targeted one (`udl:idx:user:*:cid:*`, which is one key per (uid,cid), not
|
||||
// per record) run once at boot rather than per read — the distinction that
|
||||
// makes the difference between a migration and the pathology this index exists
|
||||
// to avoid.
|
||||
func (c *Client) UDLBackfillUserCids() (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client == nil {
|
||||
return 0, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
|
||||
pattern := c.buildKey("udl:idx:user:*:cid:*")
|
||||
prefix := c.buildKey("udl:idx:user:")
|
||||
added := 0
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, next, err := c.client.Scan(ctx, cursor, pattern, 500).Result()
|
||||
if err != nil {
|
||||
return added, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
for _, k := range keys {
|
||||
// <prefix><uid>:cid:<cid> — split on the FIRST separator, not the
|
||||
// last. A uid is multibase base58btc, whose alphabet contains no
|
||||
// colon at all, so the first ":cid:" always ends the uid. A cid
|
||||
// that happens to contain ":cid:" in its own name would make the
|
||||
// last occurrence split in the wrong place.
|
||||
rest := strings.TrimPrefix(k, prefix)
|
||||
i := strings.Index(rest, ":cid:")
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
uid, cid := rest[:i], rest[i+len(":cid:"):]
|
||||
if uid == "" || cid == "" {
|
||||
continue
|
||||
}
|
||||
n, err := c.client.SAdd(ctx, c.buildKey(udlIdxUserCids(uid)), cid).Result()
|
||||
if err != nil {
|
||||
return added, fmt.Errorf("sadd failed: %w", err)
|
||||
}
|
||||
added += int(n)
|
||||
}
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return added, nil
|
||||
return s.Count(ctx, udlIdxUserCids(uid)+udlSetSep)
|
||||
}
|
||||
|
||||
// UDLCidUsers returns every uid that has any record for cid — uids_for_cid.
|
||||
func (c *Client) UDLCidUsers(cid string) ([]string, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
uids, err := c.client.SMembers(ctx, c.buildKey(udlIdxCidUsers(cid))).Result()
|
||||
s, err := c.kv()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smembers failed: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
return uids, nil
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
return udlMembers(ctx, s, udlIdxCidUsers(cid))
|
||||
}
|
||||
|
||||
// UDLPublicValues returns the live plaintext public JSON scalar values for
|
||||
// (cid, key) across all uids — the input to aggregation. Cells without a value
|
||||
// field (private-tier or tombstoned) are skipped.
|
||||
// (private-tier or tombstoned) are skipped.
|
||||
func (c *Client) UDLPublicValues(cid, key string) ([]string, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
s, err := c.kv()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
|
||||
uids, err := c.client.SMembers(ctx, c.buildKey(udlIdxCidKey(cid, key))).Result()
|
||||
uids, err := udlMembers(ctx, s, udlIdxCidKey(cid, key))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smembers failed: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
recKeys := make([]string, len(uids))
|
||||
for i, uid := range uids {
|
||||
recKeys[i] = udlRecKey(uid, cid, key)
|
||||
}
|
||||
cells, err := readUDLCells(ctx, s, recKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(uids))
|
||||
for _, uid := range uids {
|
||||
v, err := c.client.HGet(ctx, c.buildKey(udlRecKey(uid, cid, key)), "value").Result()
|
||||
if err == redis.Nil {
|
||||
continue // private-tier or tombstoned
|
||||
for _, k := range recKeys {
|
||||
if cell, ok := cells[k]; ok && cell.HasValue {
|
||||
out = append(out, cell.Value)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hget failed: %w", err)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user