mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 08:44:14 +00:00
408 lines
12 KiB
Go
408 lines
12 KiB
Go
package pds
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/bluesky-social/indigo/repo"
|
|
"github.com/ipfs/go-cid"
|
|
)
|
|
|
|
// IncrementStats increments the pull or push count for a repository
|
|
// operation should be "pull" or "push"
|
|
// Creates a new record if none exists, updates existing record otherwise
|
|
func (p *HoldPDS) IncrementStats(ctx context.Context, ownerDID, repository, operation string) error {
|
|
if operation != "pull" && operation != "push" {
|
|
return fmt.Errorf("invalid operation: %s (must be 'pull' or 'push')", operation)
|
|
}
|
|
|
|
rkey := atproto.StatsRecordKey(ownerDID, repository)
|
|
now := time.Now().Format(time.RFC3339)
|
|
|
|
// Try to get existing record
|
|
_, existing, err := p.GetStats(ctx, ownerDID, repository)
|
|
if err != nil {
|
|
// Record doesn't exist - create new one
|
|
record := atproto.NewStatsRecord(ownerDID, repository)
|
|
if operation == "pull" {
|
|
record.PullCount = 1
|
|
record.LastPull = now
|
|
} else {
|
|
record.PushCount = 1
|
|
record.LastPush = now
|
|
}
|
|
record.UpdatedAt = now
|
|
|
|
_, _, err := p.repomgr.PutRecord(ctx, p.uid, atproto.StatsCollection, rkey, record)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create stats record: %w", err)
|
|
}
|
|
|
|
slog.Debug("Created stats record",
|
|
"ownerDID", ownerDID,
|
|
"repository", repository,
|
|
"operation", operation)
|
|
return nil
|
|
}
|
|
|
|
// Record exists - update it
|
|
if operation == "pull" {
|
|
existing.PullCount++
|
|
existing.LastPull = now
|
|
} else {
|
|
existing.PushCount++
|
|
existing.LastPush = now
|
|
}
|
|
existing.UpdatedAt = now
|
|
|
|
_, err = p.repomgr.UpdateRecord(ctx, p.uid, atproto.StatsCollection, rkey, existing)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update stats record: %w", err)
|
|
}
|
|
|
|
slog.Debug("Updated stats record",
|
|
"ownerDID", ownerDID,
|
|
"repository", repository,
|
|
"operation", operation,
|
|
"pullCount", existing.PullCount,
|
|
"pushCount", existing.PushCount)
|
|
return nil
|
|
}
|
|
|
|
// IncrementDailyStats increments the daily pull or push count for a repository
|
|
// Creates a new daily record if none exists for the current date, updates existing otherwise
|
|
// On first daily record for a repo, seeds it with existing cumulative stats so trend charts
|
|
// have a starting data point for pre-daily-tracking history
|
|
func (p *HoldPDS) IncrementDailyStats(ctx context.Context, ownerDID, repository, operation string) error {
|
|
if operation != "pull" && operation != "push" {
|
|
return fmt.Errorf("invalid operation: %s (must be 'pull' or 'push')", operation)
|
|
}
|
|
|
|
date := time.Now().UTC().Format("2006-01-02")
|
|
rkey := atproto.DailyStatsRecordKey(ownerDID, repository, date)
|
|
now := time.Now().Format(time.RFC3339)
|
|
|
|
// Try to get existing daily record for today
|
|
_, existing, err := p.GetDailyStats(ctx, ownerDID, repository, date)
|
|
if err != nil {
|
|
// No daily record for today — check if we need to seed from cumulative stats
|
|
seedPull, seedPush := p.getSeedCounts(ctx, ownerDID, repository)
|
|
|
|
record := atproto.NewDailyStatsRecord(ownerDID, repository, date)
|
|
if operation == "pull" {
|
|
record.PullCount = 1
|
|
} else {
|
|
record.PushCount = 1
|
|
}
|
|
record.UpdatedAt = now
|
|
|
|
// If there are existing cumulative counts but no daily records yet,
|
|
// create a seed record for the previous day with the historical totals
|
|
if seedPull > 0 || seedPush > 0 {
|
|
if err := p.seedDailyStats(ctx, ownerDID, repository, date, seedPull, seedPush); err != nil {
|
|
slog.Warn("Failed to seed daily stats from cumulative",
|
|
"ownerDID", ownerDID,
|
|
"repository", repository,
|
|
"error", err)
|
|
}
|
|
}
|
|
|
|
_, _, err := p.repomgr.PutRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, record)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create daily stats record: %w", err)
|
|
}
|
|
|
|
slog.Debug("Created daily stats record",
|
|
"ownerDID", ownerDID,
|
|
"repository", repository,
|
|
"date", date,
|
|
"operation", operation)
|
|
return nil
|
|
}
|
|
|
|
// Record exists — increment
|
|
if operation == "pull" {
|
|
existing.PullCount++
|
|
} else {
|
|
existing.PushCount++
|
|
}
|
|
existing.UpdatedAt = now
|
|
|
|
_, err = p.repomgr.UpdateRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, existing)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update daily stats record: %w", err)
|
|
}
|
|
|
|
slog.Debug("Updated daily stats record",
|
|
"ownerDID", ownerDID,
|
|
"repository", repository,
|
|
"date", date,
|
|
"operation", operation,
|
|
"pullCount", existing.PullCount,
|
|
"pushCount", existing.PushCount)
|
|
return nil
|
|
}
|
|
|
|
// getSeedCounts returns the cumulative pull/push counts from io.atcr.hold.stats
|
|
// minus any already-tracked daily counts. Returns (0, 0) if no seeding is needed.
|
|
func (p *HoldPDS) getSeedCounts(ctx context.Context, ownerDID, repository string) (int64, int64) {
|
|
// Check if any daily records already exist for this repo
|
|
dailyStats, err := p.ListDailyStatsForRepo(ctx, ownerDID, repository)
|
|
if err == nil && len(dailyStats) > 0 {
|
|
// Daily records already exist — no seeding needed
|
|
return 0, 0
|
|
}
|
|
|
|
// Get cumulative stats
|
|
_, cumulative, err := p.GetStats(ctx, ownerDID, repository)
|
|
if err != nil || cumulative == nil {
|
|
return 0, 0
|
|
}
|
|
|
|
return cumulative.PullCount, cumulative.PushCount
|
|
}
|
|
|
|
// seedDailyStats creates a seed daily record with historical cumulative totals
|
|
// dated to the day before the first real daily record
|
|
func (p *HoldPDS) seedDailyStats(ctx context.Context, ownerDID, repository, currentDate string, pullCount, pushCount int64) error {
|
|
// Parse current date and go back one day for the seed record
|
|
t, err := time.Parse("2006-01-02", currentDate)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse date: %w", err)
|
|
}
|
|
seedDate := t.AddDate(0, 0, -1).Format("2006-01-02")
|
|
rkey := atproto.DailyStatsRecordKey(ownerDID, repository, seedDate)
|
|
|
|
record := atproto.NewDailyStatsRecord(ownerDID, repository, seedDate)
|
|
record.PullCount = pullCount
|
|
record.PushCount = pushCount
|
|
record.UpdatedAt = time.Now().Format(time.RFC3339)
|
|
|
|
_, _, err = p.repomgr.PutRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, record)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create seed daily stats record: %w", err)
|
|
}
|
|
|
|
slog.Info("Seeded daily stats from cumulative totals",
|
|
"ownerDID", ownerDID,
|
|
"repository", repository,
|
|
"seedDate", seedDate,
|
|
"pullCount", pullCount,
|
|
"pushCount", pushCount)
|
|
return nil
|
|
}
|
|
|
|
// GetDailyStats retrieves the daily stats record for a repository on a specific date
|
|
func (p *HoldPDS) GetDailyStats(ctx context.Context, ownerDID, repository, date string) (cid.Cid, *atproto.DailyStatsRecord, error) {
|
|
rkey := atproto.DailyStatsRecordKey(ownerDID, repository, date)
|
|
|
|
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, cid.Undef)
|
|
if err != nil {
|
|
return cid.Undef, nil, err
|
|
}
|
|
|
|
dailyRecord, ok := val.(*atproto.DailyStatsRecord)
|
|
if !ok {
|
|
return cid.Undef, nil, fmt.Errorf("unexpected type for daily stats record: %T", val)
|
|
}
|
|
|
|
return recordCID, dailyRecord, nil
|
|
}
|
|
|
|
// ListDailyStatsForRepo returns all daily stats records for a specific owner+repo
|
|
func (p *HoldPDS) ListDailyStatsForRepo(ctx context.Context, ownerDID, repository string) ([]*atproto.DailyStatsRecord, error) {
|
|
session, err := p.carstore.ReadOnlySession(p.uid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get read-only 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() {
|
|
return []*atproto.DailyStatsRecord{}, nil
|
|
}
|
|
|
|
r, err := repo.OpenRepo(ctx, session, head)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open repo: %w", err)
|
|
}
|
|
|
|
var stats []*atproto.DailyStatsRecord
|
|
|
|
err = r.ForEach(ctx, atproto.DailyStatsCollection, func(k string, v cid.Cid) error {
|
|
parts := strings.Split(k, "/")
|
|
if len(parts) < 2 {
|
|
return nil
|
|
}
|
|
|
|
actualCollection := strings.Join(parts[:len(parts)-1], "/")
|
|
if actualCollection != atproto.DailyStatsCollection {
|
|
return repo.ErrDoneIterating
|
|
}
|
|
|
|
_, recBytes, err := r.GetRecordBytes(ctx, k)
|
|
if err != nil {
|
|
slog.Warn("Failed to get daily stats record bytes", "key", k, "error", err)
|
|
return nil
|
|
}
|
|
|
|
if recBytes == nil {
|
|
return nil
|
|
}
|
|
|
|
var dailyRecord atproto.DailyStatsRecord
|
|
if err := dailyRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
|
|
slog.Warn("Failed to unmarshal daily stats record", "key", k, "error", err)
|
|
return nil
|
|
}
|
|
|
|
if dailyRecord.OwnerDID == ownerDID && dailyRecord.Repository == repository {
|
|
stats = append(stats, &dailyRecord)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
if errors.Is(err, repo.ErrDoneIterating) {
|
|
// Expected
|
|
} else if strings.Contains(err.Error(), "not found") {
|
|
return []*atproto.DailyStatsRecord{}, nil
|
|
} else {
|
|
return nil, fmt.Errorf("failed to iterate daily stats records: %w", err)
|
|
}
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// GetStats retrieves the stats record for a repository
|
|
// Returns nil, nil if no stats record exists
|
|
func (p *HoldPDS) GetStats(ctx context.Context, ownerDID, repository string) (cid.Cid, *atproto.StatsRecord, error) {
|
|
rkey := atproto.StatsRecordKey(ownerDID, repository)
|
|
|
|
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.StatsCollection, rkey, cid.Undef)
|
|
if err != nil {
|
|
return cid.Undef, nil, err
|
|
}
|
|
|
|
statsRecord, ok := val.(*atproto.StatsRecord)
|
|
if !ok {
|
|
return cid.Undef, nil, fmt.Errorf("unexpected type for stats record: %T", val)
|
|
}
|
|
|
|
return recordCID, statsRecord, nil
|
|
}
|
|
|
|
// ListStats returns all stats records in the hold's PDS
|
|
// This is used by AppView to aggregate stats from all holds
|
|
func (p *HoldPDS) ListStats(ctx context.Context) ([]*atproto.StatsRecord, error) {
|
|
// Get read-only session from carstore
|
|
session, err := p.carstore.ReadOnlySession(p.uid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get read-only session: %w", err)
|
|
}
|
|
|
|
// Get repo head
|
|
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() {
|
|
// No repo yet, return empty list
|
|
return []*atproto.StatsRecord{}, nil
|
|
}
|
|
|
|
// Open repo
|
|
r, err := repo.OpenRepo(ctx, session, head)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open repo: %w", err)
|
|
}
|
|
|
|
var stats []*atproto.StatsRecord
|
|
|
|
// Iterate over all stats records
|
|
err = r.ForEach(ctx, atproto.StatsCollection, func(k string, v cid.Cid) error {
|
|
// Extract collection and rkey from full path (k is like "io.atcr.hold.stats/abcd1234...")
|
|
parts := strings.Split(k, "/")
|
|
if len(parts) < 2 {
|
|
return nil // Skip invalid keys
|
|
}
|
|
|
|
// Extract actual collection
|
|
actualCollection := strings.Join(parts[:len(parts)-1], "/")
|
|
|
|
// MST keys are sorted, so once we hit a different collection, stop walking
|
|
if actualCollection != atproto.StatsCollection {
|
|
return repo.ErrDoneIterating
|
|
}
|
|
|
|
// Get record bytes
|
|
_, recBytes, err := r.GetRecordBytes(ctx, k)
|
|
if err != nil {
|
|
slog.Warn("Failed to get stats record bytes", "key", k, "error", err)
|
|
return nil // Continue with other records
|
|
}
|
|
|
|
if recBytes == nil {
|
|
return nil
|
|
}
|
|
|
|
// Unmarshal the CBOR bytes
|
|
var statsRecord atproto.StatsRecord
|
|
if err := statsRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
|
|
slog.Warn("Failed to unmarshal stats record", "key", k, "error", err)
|
|
return nil // Continue with other records
|
|
}
|
|
|
|
stats = append(stats, &statsRecord)
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
// ErrDoneIterating is expected when we stop walking early
|
|
if errors.Is(err, repo.ErrDoneIterating) {
|
|
// Successfully stopped at collection boundary
|
|
} else if strings.Contains(err.Error(), "not found") {
|
|
// Collection doesn't exist yet - return empty list
|
|
return []*atproto.StatsRecord{}, nil
|
|
} else {
|
|
return nil, fmt.Errorf("failed to iterate stats records: %w", err)
|
|
}
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// ListStatsRecordsForUser returns all stats records where the user is the repository owner
|
|
// Used for GDPR data export to return all stats for repositories owned by the user
|
|
func (p *HoldPDS) ListStatsRecordsForUser(ctx context.Context, userDID string) ([]*atproto.StatsRecord, error) {
|
|
// Get all stats records and filter by ownerDID
|
|
allStats, err := p.ListStats(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var userStats []*atproto.StatsRecord
|
|
for _, stat := range allStats {
|
|
if stat.OwnerDID == userDID {
|
|
userStats = append(userStats, stat)
|
|
}
|
|
}
|
|
|
|
if userStats == nil {
|
|
userStats = []*atproto.StatsRecord{}
|
|
}
|
|
|
|
return userStats, nil
|
|
}
|