mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-03 08:46:57 +00:00
optimize queries for admin panel
This commit is contained in:
@@ -290,14 +290,12 @@ func GetAvailableHolds(db DBTX, userDID string) ([]AvailableHold, error) {
|
||||
WHEN h.owner_did = ?1 THEN 'owner'
|
||||
WHEN c.member_did IS NOT NULL THEN 'crew'
|
||||
WHEN h.allow_all_crew = 1 THEN 'eligible'
|
||||
WHEN h.public = 1 THEN 'public'
|
||||
ELSE 'none'
|
||||
END as membership,
|
||||
c.permissions
|
||||
FROM hold_captain_records h
|
||||
LEFT JOIN hold_crew_members c ON h.hold_did = c.hold_did AND c.member_did = ?1
|
||||
WHERE h.public = 1
|
||||
OR h.allow_all_crew = 1
|
||||
WHERE h.allow_all_crew = 1
|
||||
OR h.owner_did = ?1
|
||||
OR c.member_did IS NOT NULL
|
||||
ORDER BY
|
||||
|
||||
@@ -58,7 +58,7 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Debug("Fetched profile", "component", "settings", "did", user.DID, "default_hold", profile.DefaultHold)
|
||||
|
||||
// Get available holds for dropdown
|
||||
var ownedHolds, crewHolds, eligibleHolds, publicHolds []HoldDisplay
|
||||
var ownedHolds, crewHolds, eligibleHolds []HoldDisplay
|
||||
holdDataMap := make(map[string]HoldDisplay)
|
||||
|
||||
if h.DB != nil {
|
||||
@@ -93,8 +93,6 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
crewHolds = append(crewHolds, display)
|
||||
case "eligible":
|
||||
eligibleHolds = append(eligibleHolds, display)
|
||||
case "public":
|
||||
publicHolds = append(publicHolds, display)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,12 +132,12 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
CurrentHoldDID string
|
||||
CurrentHoldDisplay string
|
||||
ShowCurrentHold bool
|
||||
AppViewDefaultHoldDID string
|
||||
AppViewDefaultHoldDisplay string
|
||||
AppViewDefaultRegion string
|
||||
OwnedHolds []HoldDisplay
|
||||
CrewHolds []HoldDisplay
|
||||
EligibleHolds []HoldDisplay
|
||||
PublicHolds []HoldDisplay
|
||||
HoldDataJSON template.JS
|
||||
}{
|
||||
PageData: NewPageData(r, &h.BaseUIHandler),
|
||||
@@ -147,12 +145,12 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
CurrentHoldDID: profile.DefaultHold,
|
||||
CurrentHoldDisplay: deriveDisplayName(profile.DefaultHold),
|
||||
ShowCurrentHold: showCurrentHold,
|
||||
AppViewDefaultHoldDID: h.DefaultHoldDID,
|
||||
AppViewDefaultHoldDisplay: appViewDefaultDisplay,
|
||||
AppViewDefaultRegion: appViewDefaultRegion,
|
||||
OwnedHolds: ownedHolds,
|
||||
CrewHolds: crewHolds,
|
||||
EligibleHolds: eligibleHolds,
|
||||
PublicHolds: publicHolds,
|
||||
HoldDataJSON: template.JS(holdDataJSON),
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
<span class="label-text">Storage Hold</span>
|
||||
</label>
|
||||
<select id="default-hold" name="hold_did" class="select select-bordered w-full" autocomplete="off">
|
||||
<option value=""{{ if eq .CurrentHoldDID "" }} selected{{ end }}>AppView Default ({{ .AppViewDefaultHoldDisplay }}{{ if .AppViewDefaultRegion }}, {{ .AppViewDefaultRegion }}{{ end }})</option>
|
||||
<option value="{{ .AppViewDefaultHoldDID }}"{{ if or (eq .CurrentHoldDID "") (eq .CurrentHoldDID .AppViewDefaultHoldDID) }} selected{{ end }}>AppView Default ({{ .AppViewDefaultHoldDisplay }}{{ if .AppViewDefaultRegion }}, {{ .AppViewDefaultRegion }}{{ end }})</option>
|
||||
|
||||
{{ if .ShowCurrentHold }}
|
||||
<option value="{{ .CurrentHoldDID }}" selected>Current ({{ .CurrentHoldDisplay }})</option>
|
||||
@@ -178,15 +178,6 @@
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .PublicHolds }}
|
||||
<optgroup label="Public Holds">
|
||||
{{ range .PublicHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
</select>
|
||||
<p class="text-sm text-base-content/60 mt-1">Your images will be stored on the selected hold</p>
|
||||
</fieldset>
|
||||
|
||||
+15
-26
@@ -93,22 +93,17 @@ func (ui *AdminUI) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate total storage by summing quota for all crew members
|
||||
// Calculate total storage with a single bulk query
|
||||
var totalSize int64
|
||||
uniqueDigests := 0
|
||||
|
||||
crew, err := ui.pds.ListCrewMembers(ctx)
|
||||
allQuotas, err := ui.pds.GetAllUserQuotas(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to list crew for stats", "error", err)
|
||||
slog.Warn("Failed to get all user quotas", "error", err)
|
||||
} else {
|
||||
// Get usage for each crew member
|
||||
for _, member := range crew {
|
||||
quotaStats, err := ui.pds.GetQuotaForUser(ctx, member.Record.Member)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
totalSize += quotaStats.TotalSize
|
||||
uniqueDigests += quotaStats.UniqueBlobs
|
||||
for _, q := range allQuotas {
|
||||
totalSize += q.TotalSize
|
||||
uniqueDigests += q.UniqueBlobs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,28 +147,22 @@ func (ui *AdminUI) handleTopUsersAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get all crew members and their usage
|
||||
crew, err := ui.pds.ListCrewMembers(ctx)
|
||||
// Get all user quotas in a single bulk query
|
||||
allQuotas, err := ui.pds.GetAllUserQuotas(ctx)
|
||||
if err != nil {
|
||||
slog.Error("Failed to list crew members for top users", "error", err)
|
||||
slog.Error("Failed to get all user quotas", "error", err)
|
||||
http.Error(w, "Failed to load top users", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var users []UserUsage
|
||||
for _, member := range crew {
|
||||
quotaStats, err := ui.pds.GetQuotaForUser(ctx, member.Record.Member)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get quota for user", "did", member.Record.Member, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for did, q := range allQuotas {
|
||||
users = append(users, UserUsage{
|
||||
DID: member.Record.Member,
|
||||
Handle: resolveHandle(ctx, member.Record.Member),
|
||||
Usage: quotaStats.TotalSize,
|
||||
UsageHuman: formatHumanBytes(quotaStats.TotalSize),
|
||||
BlobCount: quotaStats.UniqueBlobs,
|
||||
DID: did,
|
||||
Handle: resolveHandle(ctx, did),
|
||||
Usage: q.TotalSize,
|
||||
UsageHuman: formatHumanBytes(q.TotalSize),
|
||||
BlobCount: q.UniqueBlobs,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/hold/pds"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -56,14 +57,11 @@ func (ui *AdminUI) getCrewViews(ctx context.Context) ([]CrewMemberView, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userUsage := make(map[string]int64)
|
||||
for _, member := range crew {
|
||||
quotaStats, err := ui.pds.GetQuotaForUser(ctx, member.Record.Member)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get quota for crew member", "did", member.Record.Member, "error", err)
|
||||
continue
|
||||
}
|
||||
userUsage[member.Record.Member] = quotaStats.TotalSize
|
||||
// Single bulk query for all user quotas
|
||||
allQuotas, err := ui.pds.GetAllUserQuotas(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get all user quotas for crew views", "error", err)
|
||||
allQuotas = make(map[string]*pds.QuotaStats)
|
||||
}
|
||||
|
||||
defaultTier := "default"
|
||||
@@ -88,11 +86,16 @@ func (ui *AdminUI) getCrewViews(ctx context.Context) ([]CrewMemberView, error) {
|
||||
AddedAt: parseTime(member.Record.AddedAt),
|
||||
}
|
||||
|
||||
usage := int64(0)
|
||||
if q, ok := allQuotas[member.Record.Member]; ok {
|
||||
usage = q.TotalSize
|
||||
}
|
||||
|
||||
if ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() {
|
||||
if limit := ui.quotaMgr.GetTierLimit(tier); limit != nil {
|
||||
view.TierLimit = formatHumanBytes(*limit)
|
||||
if *limit > 0 {
|
||||
view.UsagePercent = int(float64(userUsage[view.DID]) / float64(*limit) * 100)
|
||||
view.UsagePercent = int(float64(usage) / float64(*limit) * 100)
|
||||
}
|
||||
} else {
|
||||
view.TierLimit = "Unlimited"
|
||||
@@ -101,7 +104,7 @@ func (ui *AdminUI) getCrewViews(ctx context.Context) ([]CrewMemberView, error) {
|
||||
view.TierLimit = "Unlimited"
|
||||
}
|
||||
|
||||
view.CurrentUsage = userUsage[view.DID]
|
||||
view.CurrentUsage = usage
|
||||
view.UsageHuman = formatHumanBytes(view.CurrentUsage)
|
||||
|
||||
crewViews = append(crewViews, view)
|
||||
|
||||
@@ -215,7 +215,7 @@ func TestDeleteAndListBlueskyPosts_WithPosts(t *testing.T) {
|
||||
|
||||
// Index the record
|
||||
if pds.recordsIndex != nil {
|
||||
err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID)
|
||||
err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to index test post: %v", err)
|
||||
}
|
||||
@@ -304,7 +304,7 @@ func TestDeleteUserData_IncludesPosts(t *testing.T) {
|
||||
|
||||
// Index the record
|
||||
if pds.recordsIndex != nil {
|
||||
err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID)
|
||||
err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to index test post: %v", err)
|
||||
}
|
||||
|
||||
+31
-91
@@ -89,100 +89,48 @@ type QuotaStats struct {
|
||||
Tier string `json:"tier,omitempty"` // quota tier (e.g., 'deckhand', 'bosun', 'quartermaster')
|
||||
}
|
||||
|
||||
// GetQuotaForUser calculates storage quota for a specific user
|
||||
// It iterates through all layer records, filters by userDid, deduplicates by digest,
|
||||
// and sums the sizes of unique blobs.
|
||||
// GetQuotaForUser calculates storage quota for a specific user.
|
||||
// Uses SQL aggregation over the denormalized digest/size columns in the records index.
|
||||
func (p *HoldPDS) GetQuotaForUser(ctx context.Context, userDID string) (*QuotaStats, error) {
|
||||
if p.recordsIndex == nil {
|
||||
return nil, fmt.Errorf("records index not available")
|
||||
}
|
||||
|
||||
// Get session for reading record data
|
||||
session, err := p.carstore.ReadOnlySession(p.uid)
|
||||
uniqueBlobs, totalSize, err := p.recordsIndex.QuotaForDID(atproto.LayerCollection, userDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get repo head: %w", err)
|
||||
}
|
||||
|
||||
if !head.Defined() {
|
||||
// Empty repo - return zero stats
|
||||
return &QuotaStats{UserDID: userDID}, nil
|
||||
}
|
||||
|
||||
repoHandle, err := repo.OpenRepo(ctx, session, head)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open repo: %w", err)
|
||||
}
|
||||
|
||||
// Track unique digests and their sizes
|
||||
digestSizes := make(map[string]int64)
|
||||
|
||||
// Iterate all layer records via the index
|
||||
cursor := ""
|
||||
batchSize := 1000 // Process in batches
|
||||
|
||||
for {
|
||||
records, nextCursor, err := p.recordsIndex.ListRecords(atproto.LayerCollection, batchSize, cursor, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list layer records: %w", err)
|
||||
}
|
||||
|
||||
for _, rec := range records {
|
||||
// Construct record path and get the record data
|
||||
recordPath := rec.Collection + "/" + rec.Rkey
|
||||
|
||||
_, recBytes, err := repoHandle.GetRecordBytes(ctx, recordPath)
|
||||
if err != nil {
|
||||
// Skip records we can't read
|
||||
continue
|
||||
}
|
||||
|
||||
// Decode the layer record
|
||||
recordValue, err := lexutil.CborDecodeValue(*recBytes)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
layerRecord, ok := recordValue.(*atproto.LayerRecord)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by userDID
|
||||
if layerRecord.UserDID != userDID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Deduplicate by digest - keep the size (could be different pushes of same blob)
|
||||
// Store the size - we only count each unique digest once
|
||||
if _, exists := digestSizes[layerRecord.Digest]; !exists {
|
||||
digestSizes[layerRecord.Digest] = layerRecord.Size
|
||||
}
|
||||
}
|
||||
|
||||
if nextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
var totalSize int64
|
||||
for _, size := range digestSizes {
|
||||
totalSize += size
|
||||
return nil, fmt.Errorf("failed to query quota: %w", err)
|
||||
}
|
||||
|
||||
return &QuotaStats{
|
||||
UserDID: userDID,
|
||||
UniqueBlobs: len(digestSizes),
|
||||
UniqueBlobs: uniqueBlobs,
|
||||
TotalSize: totalSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAllUserQuotas returns quota stats for all users in a single SQL query.
|
||||
// Used by admin endpoints to avoid N+1 per-user quota lookups.
|
||||
func (p *HoldPDS) GetAllUserQuotas(ctx context.Context) (map[string]*QuotaStats, error) {
|
||||
if p.recordsIndex == nil {
|
||||
return nil, fmt.Errorf("records index not available")
|
||||
}
|
||||
|
||||
quotas, err := p.recordsIndex.QuotasByDID(atproto.LayerCollection)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query all quotas: %w", err)
|
||||
}
|
||||
|
||||
result := make(map[string]*QuotaStats, len(quotas))
|
||||
for did, q := range quotas {
|
||||
result[did] = &QuotaStats{
|
||||
UserDID: did,
|
||||
UniqueBlobs: q.UniqueBlobs,
|
||||
TotalSize: q.TotalSize,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetQuotaForUserWithTier calculates quota with tier-aware limits
|
||||
// It returns the base quota stats plus the tier limit and tier name.
|
||||
// Captain (owner) always has unlimited quota.
|
||||
@@ -262,27 +210,24 @@ func (p *HoldPDS) ListLayerRecordsForUser(ctx context.Context, userDID string) (
|
||||
|
||||
var records []*atproto.LayerRecord
|
||||
|
||||
// Iterate all layer records via the index
|
||||
// Iterate layer records for this user via the index (filtered by DID in SQL)
|
||||
cursor := ""
|
||||
batchSize := 1000 // Process in batches
|
||||
batchSize := 1000
|
||||
|
||||
for {
|
||||
indexRecords, nextCursor, err := p.recordsIndex.ListRecords(atproto.LayerCollection, batchSize, cursor, true)
|
||||
indexRecords, nextCursor, err := p.recordsIndex.ListRecordsByDID(atproto.LayerCollection, userDID, batchSize, cursor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list layer records: %w", err)
|
||||
}
|
||||
|
||||
for _, rec := range indexRecords {
|
||||
// Construct record path and get the record data
|
||||
recordPath := rec.Collection + "/" + rec.Rkey
|
||||
|
||||
_, recBytes, err := repoHandle.GetRecordBytes(ctx, recordPath)
|
||||
if err != nil {
|
||||
// Skip records we can't read
|
||||
continue
|
||||
}
|
||||
|
||||
// Decode the layer record
|
||||
recordValue, err := lexutil.CborDecodeValue(*recBytes)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -293,11 +238,6 @@ func (p *HoldPDS) ListLayerRecordsForUser(ctx context.Context, userDID string) (
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by userDID
|
||||
if layerRecord.UserDID != userDID {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, layerRecord)
|
||||
}
|
||||
|
||||
|
||||
+94
-13
@@ -36,6 +36,8 @@ CREATE TABLE IF NOT EXISTS records (
|
||||
rkey TEXT NOT NULL,
|
||||
cid TEXT NOT NULL,
|
||||
did TEXT,
|
||||
digest TEXT,
|
||||
size INTEGER,
|
||||
PRIMARY KEY (collection, rkey)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_collection_rkey ON records(collection, rkey);
|
||||
@@ -54,18 +56,26 @@ func NewRecordsIndex(dbPath string) (*RecordsIndex, error) {
|
||||
return nil, fmt.Errorf("failed to open records database: %w", err)
|
||||
}
|
||||
|
||||
// Check if table exists and has the did column
|
||||
// Check if table exists and has required columns
|
||||
needsRebuild := false
|
||||
var tableName string
|
||||
err = db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='records'`).Scan(&tableName)
|
||||
if err == nil {
|
||||
// Table exists, check for did column
|
||||
var colCount int
|
||||
// Check for did column
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('records') WHERE name='did'`).Scan(&colCount)
|
||||
if err != nil || colCount == 0 {
|
||||
needsRebuild = true
|
||||
slog.Info("Records index schema outdated, rebuilding with did column")
|
||||
}
|
||||
// Check for digest column (added for SQL-based quota queries)
|
||||
if !needsRebuild {
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('records') WHERE name='digest'`).Scan(&colCount)
|
||||
if err != nil || colCount == 0 {
|
||||
needsRebuild = true
|
||||
slog.Info("Records index schema outdated, rebuilding with digest/size columns")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if needsRebuild {
|
||||
@@ -91,17 +101,26 @@ func NewRecordsIndex(dbPath string) (*RecordsIndex, error) {
|
||||
// NewRecordsIndexWithDB creates a records index using an existing *sql.DB connection.
|
||||
// The caller is responsible for the DB lifecycle.
|
||||
func NewRecordsIndexWithDB(db *sql.DB) (*RecordsIndex, error) {
|
||||
// Check if table exists and has the did column
|
||||
// Check if table exists and has required columns
|
||||
needsRebuild := false
|
||||
var tableName string
|
||||
err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='records'`).Scan(&tableName)
|
||||
if err == nil {
|
||||
var colCount int
|
||||
// Check for did column
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('records') WHERE name='did'`).Scan(&colCount)
|
||||
if err != nil || colCount == 0 {
|
||||
needsRebuild = true
|
||||
slog.Info("Records index schema outdated, rebuilding with did column")
|
||||
}
|
||||
// Check for digest column (added for SQL-based quota queries)
|
||||
if !needsRebuild {
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('records') WHERE name='digest'`).Scan(&colCount)
|
||||
if err != nil || colCount == 0 {
|
||||
needsRebuild = true
|
||||
slog.Info("Records index schema outdated, rebuilding with digest/size columns")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if needsRebuild {
|
||||
@@ -129,12 +148,15 @@ func (ri *RecordsIndex) Close() error {
|
||||
}
|
||||
|
||||
// IndexRecord adds or updates a record in the index
|
||||
// did parameter is optional - pass empty string if not applicable
|
||||
func (ri *RecordsIndex) IndexRecord(collection, rkey, cidStr, did string) error {
|
||||
// did, digest, size parameters are optional - pass empty/zero if not applicable
|
||||
func (ri *RecordsIndex) IndexRecord(collection, rkey, cidStr, did, digest string, size int64) error {
|
||||
_, err := ri.db.Exec(`
|
||||
INSERT OR REPLACE INTO records (collection, rkey, cid, did)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, collection, rkey, cidStr, sql.NullString{String: did, Valid: did != ""})
|
||||
INSERT OR REPLACE INTO records (collection, rkey, cid, did, digest, size)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, collection, rkey, cidStr,
|
||||
sql.NullString{String: did, Valid: did != ""},
|
||||
sql.NullString{String: digest, Valid: digest != ""},
|
||||
sql.NullInt64{Int64: size, Valid: size > 0})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -328,8 +350,8 @@ func (ri *RecordsIndex) BackfillFromRepo(ctx context.Context, repoHandle *repo.R
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT OR REPLACE INTO records (collection, rkey, cid, did)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT OR REPLACE INTO records (collection, rkey, cid, did, digest, size)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||
@@ -345,14 +367,19 @@ func (ri *RecordsIndex) BackfillFromRepo(ctx context.Context, repoHandle *repo.R
|
||||
}
|
||||
collection, rkey := parts[0], parts[1]
|
||||
|
||||
// Extract DID from record content based on collection type
|
||||
var did string
|
||||
// Extract fields from record content based on collection type
|
||||
var did, digest string
|
||||
var size int64
|
||||
_, recBytes, err := repoHandle.GetRecordBytes(ctx, key)
|
||||
if err == nil && recBytes != nil {
|
||||
did = extractDIDFromRecord(collection, *recBytes)
|
||||
digest, size = extractLayerFieldsFromRecord(collection, *recBytes)
|
||||
}
|
||||
|
||||
_, err = stmt.Exec(collection, rkey, c.String(), sql.NullString{String: did, Valid: did != ""})
|
||||
_, err = stmt.Exec(collection, rkey, c.String(),
|
||||
sql.NullString{String: did, Valid: did != ""},
|
||||
sql.NullString{String: digest, Valid: digest != ""},
|
||||
sql.NullInt64{Int64: size, Valid: size > 0})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to index record %s: %w", key, err)
|
||||
}
|
||||
@@ -392,6 +419,60 @@ func splitStatements(sql string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// QuotaForDID returns unique blob count and total size for a single DID in a collection.
|
||||
// Uses SQL aggregation over the denormalized digest/size columns.
|
||||
func (ri *RecordsIndex) QuotaForDID(collection, did string) (uniqueBlobs int, totalSize int64, err error) {
|
||||
err = ri.db.QueryRow(`
|
||||
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
||||
FROM (SELECT DISTINCT digest, size FROM records WHERE collection = ? AND did = ? AND digest IS NOT NULL)
|
||||
`, collection, did).Scan(&uniqueBlobs, &totalSize)
|
||||
return
|
||||
}
|
||||
|
||||
// QuotasByDID returns unique blob count and total size grouped by DID.
|
||||
// Single query replaces N individual quota lookups.
|
||||
func (ri *RecordsIndex) QuotasByDID(collection string) (map[string]QuotaResult, error) {
|
||||
rows, err := ri.db.Query(`
|
||||
SELECT did, COUNT(*), COALESCE(SUM(size), 0)
|
||||
FROM (SELECT DISTINCT did, digest, size FROM records WHERE collection = ? AND did IS NOT NULL AND digest IS NOT NULL)
|
||||
GROUP BY did
|
||||
`, collection)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query quotas by DID: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]QuotaResult)
|
||||
for rows.Next() {
|
||||
var did string
|
||||
var qr QuotaResult
|
||||
if err := rows.Scan(&did, &qr.UniqueBlobs, &qr.TotalSize); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan quota row: %w", err)
|
||||
}
|
||||
result[did] = qr
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// QuotaResult holds aggregated quota data from SQL queries
|
||||
type QuotaResult struct {
|
||||
UniqueBlobs int
|
||||
TotalSize int64
|
||||
}
|
||||
|
||||
// extractLayerFieldsFromRecord extracts digest and size from layer records.
|
||||
// Returns empty/zero for non-layer records.
|
||||
func extractLayerFieldsFromRecord(collection string, recBytes []byte) (string, int64) {
|
||||
if collection != atproto.LayerCollection {
|
||||
return "", 0
|
||||
}
|
||||
var rec atproto.LayerRecord
|
||||
if err := rec.UnmarshalCBOR(bytes.NewReader(recBytes)); err != nil {
|
||||
return "", 0
|
||||
}
|
||||
return rec.Digest, rec.Size
|
||||
}
|
||||
|
||||
// extractDIDFromRecord extracts the associated DID from a record based on its collection type
|
||||
func extractDIDFromRecord(collection string, recBytes []byte) string {
|
||||
switch collection {
|
||||
|
||||
+154
-19
@@ -50,7 +50,7 @@ func TestRecordsIndex_IndexRecord(t *testing.T) {
|
||||
defer ri.Close()
|
||||
|
||||
// Index a record
|
||||
err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123", "")
|
||||
err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("IndexRecord() error = %v", err)
|
||||
}
|
||||
@@ -75,13 +75,13 @@ func TestRecordsIndex_IndexRecord_Upsert(t *testing.T) {
|
||||
defer ri.Close()
|
||||
|
||||
// Index a record
|
||||
err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123", "")
|
||||
err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("IndexRecord() first call error = %v", err)
|
||||
}
|
||||
|
||||
// Update the same record with new CID
|
||||
err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei456", "")
|
||||
err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei456", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("IndexRecord() second call error = %v", err)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func TestRecordsIndex_DeleteRecord(t *testing.T) {
|
||||
defer ri.Close()
|
||||
|
||||
// Index a record
|
||||
err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123", "")
|
||||
err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("IndexRecord() error = %v", err)
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func TestRecordsIndex_ListRecords_Basic(t *testing.T) {
|
||||
{"ccc", "cid3"},
|
||||
}
|
||||
for _, r := range records {
|
||||
if err := ri.IndexRecord("io.atcr.hold.crew", r.rkey, r.cid, ""); err != nil {
|
||||
if err := ri.IndexRecord("io.atcr.hold.crew", r.rkey, r.cid, "", "", 0); err != nil {
|
||||
t.Fatalf("IndexRecord() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,7 @@ func TestRecordsIndex_ListRecords_DefaultOrder(t *testing.T) {
|
||||
// Add records with different rkeys (TIDs are lexicographically ordered by time)
|
||||
rkeys := []string{"3m3aaaaaaaaa", "3m3bbbbbbbbb", "3m3ccccccccc"}
|
||||
for _, rkey := range rkeys {
|
||||
if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, ""); err != nil {
|
||||
if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, "", "", 0); err != nil {
|
||||
t.Fatalf("IndexRecord() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,7 @@ func TestRecordsIndex_ListRecords_ReverseOrder(t *testing.T) {
|
||||
// Add records
|
||||
rkeys := []string{"3m3aaaaaaaaa", "3m3bbbbbbbbb", "3m3ccccccccc"}
|
||||
for _, rkey := range rkeys {
|
||||
if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, ""); err != nil {
|
||||
if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, "", "", 0); err != nil {
|
||||
t.Fatalf("IndexRecord() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -324,7 +324,7 @@ func TestRecordsIndex_ListRecords_Limit(t *testing.T) {
|
||||
// Add 5 records
|
||||
for i := range 5 {
|
||||
rkey := string(rune('a' + i))
|
||||
if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, ""); err != nil {
|
||||
if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, "", "", 0); err != nil {
|
||||
t.Fatalf("IndexRecord() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -355,7 +355,7 @@ func TestRecordsIndex_ListRecords_Cursor(t *testing.T) {
|
||||
// Add 5 records
|
||||
rkeys := []string{"a", "b", "c", "d", "e"}
|
||||
for _, rkey := range rkeys {
|
||||
if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, ""); err != nil {
|
||||
if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, "", "", 0); err != nil {
|
||||
t.Fatalf("IndexRecord() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -430,7 +430,7 @@ func TestRecordsIndex_ListRecords_CursorReverse(t *testing.T) {
|
||||
// Add 5 records
|
||||
rkeys := []string{"a", "b", "c", "d", "e"}
|
||||
for _, rkey := range rkeys {
|
||||
if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, ""); err != nil {
|
||||
if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, "", "", 0); err != nil {
|
||||
t.Fatalf("IndexRecord() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -474,10 +474,10 @@ func TestRecordsIndex_Count(t *testing.T) {
|
||||
|
||||
// Add records to two collections
|
||||
for i := range 3 {
|
||||
ri.IndexRecord("io.atcr.hold.crew", string(rune('a'+i)), "cid1", "")
|
||||
ri.IndexRecord("io.atcr.hold.crew", string(rune('a'+i)), "cid1", "", "", 0)
|
||||
}
|
||||
for i := range 5 {
|
||||
ri.IndexRecord("io.atcr.hold.captain", string(rune('a'+i)), "cid2", "")
|
||||
ri.IndexRecord("io.atcr.hold.captain", string(rune('a'+i)), "cid2", "", "", 0)
|
||||
}
|
||||
|
||||
// Count crew
|
||||
@@ -527,10 +527,10 @@ func TestRecordsIndex_TotalCount(t *testing.T) {
|
||||
defer ri.Close()
|
||||
|
||||
// Add records to multiple collections
|
||||
ri.IndexRecord("io.atcr.hold.crew", "a", "cid1", "")
|
||||
ri.IndexRecord("io.atcr.hold.crew", "b", "cid2", "")
|
||||
ri.IndexRecord("io.atcr.hold.captain", "self", "cid3", "")
|
||||
ri.IndexRecord("io.atcr.manifest", "abc123", "cid4", "")
|
||||
ri.IndexRecord("io.atcr.hold.crew", "a", "cid1", "", "", 0)
|
||||
ri.IndexRecord("io.atcr.hold.crew", "b", "cid2", "", "", 0)
|
||||
ri.IndexRecord("io.atcr.hold.captain", "self", "cid3", "", "", 0)
|
||||
ri.IndexRecord("io.atcr.manifest", "abc123", "cid4", "", "", 0)
|
||||
|
||||
count, err := ri.TotalCount()
|
||||
if err != nil {
|
||||
@@ -581,9 +581,9 @@ func TestRecordsIndex_MultipleCollections(t *testing.T) {
|
||||
defer ri.Close()
|
||||
|
||||
// Add records to different collections with same rkeys
|
||||
ri.IndexRecord("io.atcr.hold.crew", "abc", "cid-crew", "")
|
||||
ri.IndexRecord("io.atcr.hold.captain", "abc", "cid-captain", "")
|
||||
ri.IndexRecord("io.atcr.manifest", "abc", "cid-manifest", "")
|
||||
ri.IndexRecord("io.atcr.hold.crew", "abc", "cid-crew", "", "", 0)
|
||||
ri.IndexRecord("io.atcr.hold.captain", "abc", "cid-captain", "", "", 0)
|
||||
ri.IndexRecord("io.atcr.manifest", "abc", "cid-manifest", "", "", 0)
|
||||
|
||||
// Listing should only return records from requested collection
|
||||
records, _, err := ri.ListRecords("io.atcr.hold.crew", 10, "", false)
|
||||
@@ -605,3 +605,138 @@ func TestRecordsIndex_MultipleCollections(t *testing.T) {
|
||||
t.Errorf("Expected captain count 1 after deleting crew, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordsIndex_QuotaForDID tests single-user quota aggregation
|
||||
func TestRecordsIndex_QuotaForDID(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewRecordsIndex() error = %v", err)
|
||||
}
|
||||
defer ri.Close()
|
||||
|
||||
// Add layer records for two users, with some duplicate digests
|
||||
ri.IndexRecord("io.atcr.hold.layer", "r1", "cid1", "did:plc:alice", "sha256:aaa", 100)
|
||||
ri.IndexRecord("io.atcr.hold.layer", "r2", "cid2", "did:plc:alice", "sha256:bbb", 200)
|
||||
ri.IndexRecord("io.atcr.hold.layer", "r3", "cid3", "did:plc:alice", "sha256:aaa", 100) // duplicate digest
|
||||
ri.IndexRecord("io.atcr.hold.layer", "r4", "cid4", "did:plc:bob", "sha256:ccc", 300)
|
||||
|
||||
// Alice should have 2 unique blobs, total 300 bytes
|
||||
uniqueBlobs, totalSize, err := ri.QuotaForDID("io.atcr.hold.layer", "did:plc:alice")
|
||||
if err != nil {
|
||||
t.Fatalf("QuotaForDID() error = %v", err)
|
||||
}
|
||||
if uniqueBlobs != 2 {
|
||||
t.Errorf("Expected 2 unique blobs for alice, got %d", uniqueBlobs)
|
||||
}
|
||||
if totalSize != 300 {
|
||||
t.Errorf("Expected total size 300 for alice, got %d", totalSize)
|
||||
}
|
||||
|
||||
// Bob should have 1 unique blob, total 300 bytes
|
||||
uniqueBlobs, totalSize, err = ri.QuotaForDID("io.atcr.hold.layer", "did:plc:bob")
|
||||
if err != nil {
|
||||
t.Fatalf("QuotaForDID() error = %v", err)
|
||||
}
|
||||
if uniqueBlobs != 1 {
|
||||
t.Errorf("Expected 1 unique blob for bob, got %d", uniqueBlobs)
|
||||
}
|
||||
if totalSize != 300 {
|
||||
t.Errorf("Expected total size 300 for bob, got %d", totalSize)
|
||||
}
|
||||
|
||||
// Unknown user should have 0
|
||||
uniqueBlobs, totalSize, err = ri.QuotaForDID("io.atcr.hold.layer", "did:plc:unknown")
|
||||
if err != nil {
|
||||
t.Fatalf("QuotaForDID() error = %v", err)
|
||||
}
|
||||
if uniqueBlobs != 0 || totalSize != 0 {
|
||||
t.Errorf("Expected 0/0 for unknown user, got %d/%d", uniqueBlobs, totalSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordsIndex_QuotasByDID tests bulk quota aggregation
|
||||
func TestRecordsIndex_QuotasByDID(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewRecordsIndex() error = %v", err)
|
||||
}
|
||||
defer ri.Close()
|
||||
|
||||
// Add layer records for multiple users
|
||||
ri.IndexRecord("io.atcr.hold.layer", "r1", "cid1", "did:plc:alice", "sha256:aaa", 100)
|
||||
ri.IndexRecord("io.atcr.hold.layer", "r2", "cid2", "did:plc:alice", "sha256:bbb", 200)
|
||||
ri.IndexRecord("io.atcr.hold.layer", "r3", "cid3", "did:plc:bob", "sha256:ccc", 500)
|
||||
// Non-layer record should be excluded
|
||||
ri.IndexRecord("io.atcr.hold.crew", "r4", "cid4", "did:plc:alice", "", 0)
|
||||
|
||||
quotas, err := ri.QuotasByDID("io.atcr.hold.layer")
|
||||
if err != nil {
|
||||
t.Fatalf("QuotasByDID() error = %v", err)
|
||||
}
|
||||
|
||||
if len(quotas) != 2 {
|
||||
t.Fatalf("Expected 2 users in quotas, got %d", len(quotas))
|
||||
}
|
||||
|
||||
alice := quotas["did:plc:alice"]
|
||||
if alice.UniqueBlobs != 2 {
|
||||
t.Errorf("Expected 2 unique blobs for alice, got %d", alice.UniqueBlobs)
|
||||
}
|
||||
if alice.TotalSize != 300 {
|
||||
t.Errorf("Expected total size 300 for alice, got %d", alice.TotalSize)
|
||||
}
|
||||
|
||||
bob := quotas["did:plc:bob"]
|
||||
if bob.UniqueBlobs != 1 {
|
||||
t.Errorf("Expected 1 unique blob for bob, got %d", bob.UniqueBlobs)
|
||||
}
|
||||
if bob.TotalSize != 500 {
|
||||
t.Errorf("Expected total size 500 for bob, got %d", bob.TotalSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordsIndex_QuotasByDID_Empty tests bulk quota with no records
|
||||
func TestRecordsIndex_QuotasByDID_Empty(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewRecordsIndex() error = %v", err)
|
||||
}
|
||||
defer ri.Close()
|
||||
|
||||
quotas, err := ri.QuotasByDID("io.atcr.hold.layer")
|
||||
if err != nil {
|
||||
t.Fatalf("QuotasByDID() error = %v", err)
|
||||
}
|
||||
if len(quotas) != 0 {
|
||||
t.Errorf("Expected empty quotas map, got %d entries", len(quotas))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordsIndex_QuotaForDID_IgnoresNullDigest tests that records without digest are excluded
|
||||
func TestRecordsIndex_QuotaForDID_IgnoresNullDigest(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewRecordsIndex() error = %v", err)
|
||||
}
|
||||
defer ri.Close()
|
||||
|
||||
// Record with digest+size
|
||||
ri.IndexRecord("io.atcr.hold.layer", "r1", "cid1", "did:plc:alice", "sha256:aaa", 100)
|
||||
// Record without digest (e.g. old data before migration)
|
||||
ri.IndexRecord("io.atcr.hold.layer", "r2", "cid2", "did:plc:alice", "", 0)
|
||||
|
||||
uniqueBlobs, totalSize, err := ri.QuotaForDID("io.atcr.hold.layer", "did:plc:alice")
|
||||
if err != nil {
|
||||
t.Fatalf("QuotaForDID() error = %v", err)
|
||||
}
|
||||
if uniqueBlobs != 1 {
|
||||
t.Errorf("Expected 1 unique blob (ignoring null digest), got %d", uniqueBlobs)
|
||||
}
|
||||
if totalSize != 100 {
|
||||
t.Errorf("Expected total size 100, got %d", totalSize)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -411,9 +411,10 @@ func (p *HoldPDS) CreateRecordsIndexEventHandler(broadcasterHandler func(context
|
||||
if op.RecCid != nil {
|
||||
cidStr = op.RecCid.String()
|
||||
}
|
||||
// Extract DID from record based on collection type
|
||||
// Extract fields from record based on collection type
|
||||
did := extractDIDFromOp(op)
|
||||
if err := p.recordsIndex.IndexRecord(op.Collection, op.Rkey, cidStr, did); err != nil {
|
||||
digest, size := extractLayerFieldsFromOp(op)
|
||||
if err := p.recordsIndex.IndexRecord(op.Collection, op.Rkey, cidStr, did, digest, size); err != nil {
|
||||
slog.Warn("Failed to index record", "collection", op.Collection, "rkey", op.Rkey, "error", err)
|
||||
}
|
||||
case EvtKindDeleteRecord:
|
||||
@@ -454,6 +455,17 @@ func extractDIDFromOp(op RepoOp) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractLayerFieldsFromOp extracts digest and size from a layer record operation
|
||||
func extractLayerFieldsFromOp(op RepoOp) (string, int64) {
|
||||
if op.Record == nil || op.Collection != atproto.LayerCollection {
|
||||
return "", 0
|
||||
}
|
||||
if rec, ok := op.Record.(*atproto.LayerRecord); ok {
|
||||
return rec.Digest, rec.Size
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// BackfillRecordsIndex populates the records index from existing MST data
|
||||
func (p *HoldPDS) BackfillRecordsIndex(ctx context.Context) error {
|
||||
if p.recordsIndex == nil {
|
||||
|
||||
Reference in New Issue
Block a user