248 lines
8.3 KiB
Go
248 lines
8.3 KiB
Go
package storage
|
|
|
|
// Account-scoped UDL administration: what a uid holds, and removing all of it.
|
|
//
|
|
// Both operations enumerate the same thing — every `udl:rec:<uid>/<cid>/<key>`
|
|
// cell — because there is no per-user cid index to consult. The Redis model
|
|
// (see udl.go) indexes user+key → cids and user+cid → keys, which answer
|
|
// "what is in My List", not "everything this account ever wrote". Adding a
|
|
// third index would put a write on every UDL upsert to serve two admin calls,
|
|
// so these SCAN instead and accept being O(keyspace).
|
|
//
|
|
// That cost is why UDLAllUserStats exists as a bulk call rather than a per-uid
|
|
// one: the accounts page needs a number for every account, and MATCH filters
|
|
// server-side without indexing, so N per-uid calls would walk the keyspace N
|
|
// times. One walk buckets them all.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// UDLUserStats counts what one account holds. Records is the number of cells,
|
|
// including tombstones — a retracted rating is still a row this account owns
|
|
// and still something a purge has to remove.
|
|
type UDLUserStats struct {
|
|
Records int `json:"records"`
|
|
Cids int `json:"cids"`
|
|
Keys int `json:"keys"`
|
|
}
|
|
|
|
// udlScanBatch is the SCAN COUNT hint, matching the rest of this package.
|
|
const udlScanBatch = 1000
|
|
|
|
// safeUIDForPattern rejects anything that would change the meaning of a SCAN
|
|
// glob or reach across the key structure. meta-core's uids are alphanumeric
|
|
// (identity.validUID), so this only ever fires on a hostile or malformed one —
|
|
// but it fires here, in the layer that builds the pattern, rather than relying
|
|
// on every caller having validated first.
|
|
func safeUIDForPattern(uid string) error {
|
|
if uid == "" {
|
|
return fmt.Errorf("empty uid")
|
|
}
|
|
if strings.ContainsAny(uid, "*?[]\\/:") {
|
|
return fmt.Errorf("uid contains characters that are not valid in a uid: %q", uid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// splitUDLRecSuffix splits the "<uid>/<cid>/<key>" tail of a udl:rec key.
|
|
//
|
|
// SplitN with a limit of 3 so a key containing a slash survives intact. cids
|
|
// never contain one; keys are a controlled vocabulary today, but a namespaced
|
|
// one arriving later must not silently truncate here — a truncated key would
|
|
// make the purge SREM the wrong index member and leave the real one behind.
|
|
func splitUDLRecSuffix(suffix string) (uid, cid, key string, ok bool) {
|
|
parts := strings.SplitN(suffix, "/", 3)
|
|
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
|
|
return "", "", "", false
|
|
}
|
|
return parts[0], parts[1], parts[2], true
|
|
}
|
|
|
|
// scanKeys walks the keyspace for pattern and returns every match. Caller holds
|
|
// the lock.
|
|
//
|
|
// SCAN can return the same key more than once across cursor iterations (it
|
|
// guarantees no misses, not no duplicates), so callers that count must
|
|
// de-duplicate — both callers below collect into maps or sets for that reason.
|
|
func (c *Client) scanKeys(ctx context.Context, pattern string) ([]string, error) {
|
|
var (
|
|
out []string
|
|
cursor uint64
|
|
)
|
|
for {
|
|
keys, next, err := c.client.Scan(ctx, cursor, pattern, udlScanBatch).Result()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan %q: %w", pattern, err)
|
|
}
|
|
out = append(out, keys...)
|
|
cursor = next
|
|
if cursor == 0 {
|
|
return out, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// UDLUserCells returns every (cid, key) pair this account holds a cell for.
|
|
// De-duplicated, since SCAN may repeat a key.
|
|
func (c *Client) UDLUserCells(uid string) (map[string]string, error) {
|
|
// Keyed by the full "cid/key" tail so the pair stays a pair; the value is
|
|
// the cid, which the purge needs grouped.
|
|
if err := safeUIDForPattern(uid); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if c.client == nil {
|
|
return nil, fmt.Errorf("not connected")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
prefix := c.buildKey("udl:rec:")
|
|
keys, err := c.scanKeys(ctx, prefix+uid+"/*")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[string]string, len(keys))
|
|
for _, full := range keys {
|
|
gotUID, cid, key, ok := splitUDLRecSuffix(strings.TrimPrefix(full, prefix))
|
|
// The glob can only match this uid, but a uid that is a prefix of
|
|
// another would still be caught by the '/' in the pattern; check anyway
|
|
// so a malformed key can never be attributed to the wrong account.
|
|
if !ok || gotUID != uid {
|
|
continue
|
|
}
|
|
out[cid+"/"+key] = cid
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// UDLAllUserStats counts every account's cells in one keyspace walk.
|
|
//
|
|
// Returns only accounts that actually hold records; a uid absent from the map
|
|
// holds none. Callers render a zero for those rather than treating it as an
|
|
// error — an account created a minute ago legitimately has nothing.
|
|
func (c *Client) UDLAllUserStats() (map[string]UDLUserStats, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if c.client == nil {
|
|
return nil, fmt.Errorf("not connected")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
defer cancel()
|
|
|
|
prefix := c.buildKey("udl:rec:")
|
|
keys, err := c.scanKeys(ctx, prefix+"*")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Sets rather than counters: SCAN may hand back the same key twice, which
|
|
// would otherwise inflate every number on the page.
|
|
type acc struct {
|
|
cells map[string]struct{}
|
|
cids map[string]struct{}
|
|
keys map[string]struct{}
|
|
}
|
|
byUID := make(map[string]*acc)
|
|
for _, full := range keys {
|
|
uid, cid, key, ok := splitUDLRecSuffix(strings.TrimPrefix(full, prefix))
|
|
if !ok {
|
|
continue
|
|
}
|
|
a := byUID[uid]
|
|
if a == nil {
|
|
a = &acc{
|
|
cells: make(map[string]struct{}),
|
|
cids: make(map[string]struct{}),
|
|
keys: make(map[string]struct{}),
|
|
}
|
|
byUID[uid] = a
|
|
}
|
|
a.cells[cid+"/"+key] = struct{}{}
|
|
a.cids[cid] = struct{}{}
|
|
a.keys[key] = struct{}{}
|
|
}
|
|
|
|
out := make(map[string]UDLUserStats, len(byUID))
|
|
for uid, a := range byUID {
|
|
out[uid] = UDLUserStats{Records: len(a.cells), Cids: len(a.cids), Keys: len(a.keys)}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// UDLPurgeUser removes every trace of one account from the User Data Layer:
|
|
// its cells, its three user-scoped indexes, and its membership in the shared
|
|
// cid-scoped indexes. Returns what was removed.
|
|
//
|
|
// The shared indexes are the part that is easy to miss and expensive to leave.
|
|
// `udl:idx:cid:<cid>:key:<key>` and `udl:idx:cid:<cid>:users` are keyed by cid,
|
|
// not by uid, so a purge that only deleted `udl:rec:<uid>/*` would leave this
|
|
// uid listed as an opinion-holder on every title it ever touched. Aggregation
|
|
// then HGETs a cell that no longer exists — UDLPublicValues skips it, so the
|
|
// rating average stays right, but uids_for_cid keeps naming a deleted account
|
|
// forever and every aggregate read pays for the lookup.
|
|
//
|
|
// Idempotent: purging an account with nothing stored is a no-op returning
|
|
// zeroes, so a retry after a partial failure is safe.
|
|
func (c *Client) UDLPurgeUser(uid string) (UDLUserStats, error) {
|
|
cells, err := c.UDLUserCells(uid)
|
|
if err != nil {
|
|
return UDLUserStats{}, err
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.client == nil {
|
|
return UDLUserStats{}, fmt.Errorf("not connected")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
defer cancel()
|
|
|
|
stats := UDLUserStats{Records: len(cells)}
|
|
distinctCids := make(map[string]struct{})
|
|
distinctKeys := make(map[string]struct{})
|
|
|
|
pipe := c.client.Pipeline()
|
|
for cidKey, cid := range cells {
|
|
key := strings.TrimPrefix(cidKey, cid+"/")
|
|
distinctCids[cid] = struct{}{}
|
|
distinctKeys[key] = struct{}{}
|
|
pipe.Del(ctx, c.buildKey(udlRecKey(uid, cid, key)))
|
|
pipe.SRem(ctx, c.buildKey(udlIdxCidKey(cid, key)), uid)
|
|
}
|
|
for cid := range distinctCids {
|
|
pipe.SRem(ctx, c.buildKey(udlIdxCidUsers(cid)), uid)
|
|
}
|
|
stats.Cids = len(distinctCids)
|
|
stats.Keys = len(distinctKeys)
|
|
|
|
// The three user-scoped indexes (`:key:*`, `:cid:*`, `:cids`) are enumerable
|
|
// directly, and are dropped whole rather than member-by-member. The glob is
|
|
// what keeps this correct as indexes are added — `:cids` needed no change
|
|
// here when it landed. Scanning them separately also catches
|
|
// index entries whose cell is already gone — exactly the state a previously
|
|
// half-finished purge leaves behind.
|
|
idxPrefix := c.buildKey("udl:idx:user:") + uid + ":"
|
|
idxKeys, err := c.scanKeys(ctx, idxPrefix+"*")
|
|
if err != nil {
|
|
return UDLUserStats{}, err
|
|
}
|
|
for _, k := range idxKeys {
|
|
pipe.Del(ctx, k)
|
|
}
|
|
|
|
if _, err := pipe.Exec(ctx); err != nil {
|
|
return UDLUserStats{}, fmt.Errorf("purge pipeline: %w", err)
|
|
}
|
|
return stats, nil
|
|
}
|