mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 08:44:14 +00:00
437 lines
14 KiB
Go
437 lines
14 KiB
Go
package jetstream
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bluesky-social/indigo/atproto/identity"
|
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/atproto"
|
|
)
|
|
|
|
// BackfillWorker uses com.atproto.sync.listReposByCollection to backfill historical data
|
|
type BackfillWorker struct {
|
|
db *sql.DB
|
|
client *atproto.Client
|
|
directory identity.Directory
|
|
}
|
|
|
|
// BackfillState tracks backfill progress
|
|
type BackfillState struct {
|
|
Collection string
|
|
RepoCursor string // Cursor for listReposByCollection
|
|
CurrentDID string // Current DID being processed
|
|
RecordCursor string // Cursor for listRecords within current DID
|
|
ProcessedRepos int
|
|
ProcessedRecords int
|
|
Completed bool
|
|
}
|
|
|
|
// NewBackfillWorker creates a backfill worker using sync API
|
|
func NewBackfillWorker(database *sql.DB, relayEndpoint string) (*BackfillWorker, error) {
|
|
// Create client for relay - used only for listReposByCollection
|
|
client := atproto.NewClient(relayEndpoint, "", "")
|
|
|
|
return &BackfillWorker{
|
|
db: database,
|
|
client: client, // This points to the relay
|
|
directory: identity.DefaultDirectory(),
|
|
}, nil
|
|
}
|
|
|
|
// Start runs the backfill for all ATCR collections
|
|
func (b *BackfillWorker) Start(ctx context.Context) error {
|
|
fmt.Println("Backfill: Starting sync-based backfill...")
|
|
|
|
collections := []string{
|
|
atproto.ManifestCollection, // io.atcr.manifest
|
|
atproto.TagCollection, // io.atcr.tag
|
|
atproto.StarCollection, // io.atcr.sailor.star
|
|
}
|
|
|
|
for _, collection := range collections {
|
|
fmt.Printf("Backfill: Processing collection: %s\n", collection)
|
|
|
|
if err := b.backfillCollection(ctx, collection); err != nil {
|
|
return fmt.Errorf("failed to backfill collection %s: %w", collection, err)
|
|
}
|
|
|
|
fmt.Printf("Backfill: Completed collection: %s\n", collection)
|
|
}
|
|
|
|
fmt.Println("Backfill: All collections completed!")
|
|
return nil
|
|
}
|
|
|
|
// backfillCollection backfills a single collection
|
|
func (b *BackfillWorker) backfillCollection(ctx context.Context, collection string) error {
|
|
var repoCursor string
|
|
processedRepos := 0
|
|
processedRecords := 0
|
|
|
|
// Paginate through all repos with this collection
|
|
for {
|
|
// List repos that have records in this collection
|
|
result, err := b.client.ListReposByCollection(ctx, collection, 1000, repoCursor)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list repos: %w", err)
|
|
}
|
|
|
|
fmt.Printf("Backfill: Found %d repos with %s (cursor: %s)\n", len(result.Repos), collection, repoCursor)
|
|
|
|
// Process each repo (DID)
|
|
for _, repo := range result.Repos {
|
|
recordCount, err := b.backfillRepo(ctx, repo.DID, collection)
|
|
if err != nil {
|
|
fmt.Printf("WARNING: Failed to backfill repo %s: %v\n", repo.DID, err)
|
|
continue
|
|
}
|
|
|
|
processedRepos++
|
|
processedRecords += recordCount
|
|
|
|
if processedRepos%10 == 0 {
|
|
fmt.Printf("Backfill: Progress - %d repos, %d records\n", processedRepos, processedRecords)
|
|
}
|
|
}
|
|
|
|
// Check if there are more pages
|
|
if result.Cursor == "" {
|
|
break
|
|
}
|
|
|
|
repoCursor = result.Cursor
|
|
}
|
|
|
|
fmt.Printf("Backfill: Collection %s complete - %d repos, %d records\n", collection, processedRepos, processedRecords)
|
|
return nil
|
|
}
|
|
|
|
// backfillRepo backfills all records for a single repo/DID
|
|
func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection string) (int, error) {
|
|
// Ensure user exists in database and get their PDS endpoint
|
|
if err := b.ensureUser(ctx, did); err != nil {
|
|
return 0, fmt.Errorf("failed to ensure user: %w", err)
|
|
}
|
|
|
|
// Resolve DID to get user's PDS endpoint
|
|
didParsed, err := syntax.ParseDID(did)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid DID %s: %w", did, err)
|
|
}
|
|
|
|
ident, err := b.directory.LookupDID(ctx, didParsed)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to resolve DID to PDS: %w", err)
|
|
}
|
|
|
|
pdsEndpoint := ident.PDSEndpoint()
|
|
if pdsEndpoint == "" {
|
|
return 0, fmt.Errorf("no PDS endpoint found for DID %s", did)
|
|
}
|
|
|
|
// Create a client for this user's PDS
|
|
pdsClient := atproto.NewClient(pdsEndpoint, "", "")
|
|
|
|
var recordCursor string
|
|
recordCount := 0
|
|
|
|
// Track which records exist on the PDS for reconciliation
|
|
var foundManifestDigests []string
|
|
var foundTags []struct{ Repository, Tag string }
|
|
foundStars := make(map[string]time.Time) // key: "ownerDID/repository", value: createdAt
|
|
|
|
// Paginate through all records for this repo
|
|
for {
|
|
records, cursor, err := pdsClient.ListRecordsForRepo(ctx, did, collection, 100, recordCursor)
|
|
if err != nil {
|
|
return recordCount, fmt.Errorf("failed to list records: %w", err)
|
|
}
|
|
|
|
// Process each record
|
|
for _, record := range records {
|
|
// Track what we found for deletion reconciliation
|
|
if collection == atproto.ManifestCollection {
|
|
var manifestRecord atproto.ManifestRecord
|
|
if err := json.Unmarshal(record.Value, &manifestRecord); err == nil {
|
|
foundManifestDigests = append(foundManifestDigests, manifestRecord.Digest)
|
|
}
|
|
} else if collection == atproto.TagCollection {
|
|
var tagRecord atproto.TagRecord
|
|
if err := json.Unmarshal(record.Value, &tagRecord); err == nil {
|
|
foundTags = append(foundTags, struct{ Repository, Tag string }{
|
|
Repository: tagRecord.Repository,
|
|
Tag: tagRecord.Tag,
|
|
})
|
|
}
|
|
} else if collection == atproto.StarCollection {
|
|
var starRecord atproto.StarRecord
|
|
if err := json.Unmarshal(record.Value, &starRecord); err == nil {
|
|
key := fmt.Sprintf("%s/%s", starRecord.Subject.DID, starRecord.Subject.Repository)
|
|
foundStars[key] = starRecord.CreatedAt
|
|
}
|
|
}
|
|
|
|
if err := b.processRecord(ctx, did, collection, &record); err != nil {
|
|
fmt.Printf("WARNING: Failed to process record %s: %v\n", record.URI, err)
|
|
continue
|
|
}
|
|
recordCount++
|
|
}
|
|
|
|
// Check if there are more pages
|
|
if cursor == "" {
|
|
break
|
|
}
|
|
|
|
recordCursor = cursor
|
|
}
|
|
|
|
// Reconcile deletions - remove records from DB that no longer exist on PDS
|
|
if err := b.reconcileDeletions(did, collection, foundManifestDigests, foundTags, foundStars); err != nil {
|
|
fmt.Printf("WARNING: Failed to reconcile deletions for %s: %v\n", did, err)
|
|
}
|
|
|
|
// After processing manifests, clean up orphaned tags (tags pointing to non-existent manifests)
|
|
if collection == atproto.ManifestCollection {
|
|
if err := db.CleanupOrphanedTags(b.db, did); err != nil {
|
|
fmt.Printf("WARNING: Failed to cleanup orphaned tags for %s: %v\n", did, err)
|
|
}
|
|
}
|
|
|
|
return recordCount, nil
|
|
}
|
|
|
|
// reconcileDeletions removes records from the database that no longer exist on the PDS
|
|
func (b *BackfillWorker) reconcileDeletions(did, collection string, foundManifestDigests []string, foundTags []struct{ Repository, Tag string }, foundStars map[string]time.Time) error {
|
|
switch collection {
|
|
case atproto.ManifestCollection:
|
|
// Get current manifests in DB
|
|
dbDigests, err := db.GetManifestDigestsForDID(b.db, did)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get DB manifests: %w", err)
|
|
}
|
|
|
|
// Delete manifests not found on PDS
|
|
if err := db.DeleteManifestsNotInList(b.db, did, foundManifestDigests); err != nil {
|
|
return fmt.Errorf("failed to delete orphaned manifests: %w", err)
|
|
}
|
|
|
|
// Log deletions
|
|
deleted := len(dbDigests) - len(foundManifestDigests)
|
|
if deleted > 0 {
|
|
fmt.Printf("Backfill: Deleted %d orphaned manifests for %s\n", deleted, did)
|
|
}
|
|
|
|
case atproto.TagCollection:
|
|
// Get current tags in DB
|
|
dbTags, err := db.GetTagsForDID(b.db, did)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get DB tags: %w", err)
|
|
}
|
|
|
|
// Delete tags not found on PDS
|
|
if err := db.DeleteTagsNotInList(b.db, did, foundTags); err != nil {
|
|
return fmt.Errorf("failed to delete orphaned tags: %w", err)
|
|
}
|
|
|
|
// Log deletions
|
|
deleted := len(dbTags) - len(foundTags)
|
|
if deleted > 0 {
|
|
fmt.Printf("Backfill: Deleted %d orphaned tags for %s\n", deleted, did)
|
|
}
|
|
|
|
case atproto.StarCollection:
|
|
// Reconcile stars - delete stars that no longer exist on PDS
|
|
// Star counts will be calculated on demand from the stars table
|
|
if err := db.DeleteStarsNotInList(b.db, did, foundStars); err != nil {
|
|
return fmt.Errorf("failed to delete orphaned stars: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// processRecord processes a single record and stores it in the database
|
|
func (b *BackfillWorker) processRecord(ctx context.Context, did, collection string, record *atproto.Record) error {
|
|
switch collection {
|
|
case atproto.ManifestCollection:
|
|
return b.processManifestRecord(did, record)
|
|
case atproto.TagCollection:
|
|
return b.processTagRecord(did, record)
|
|
case atproto.StarCollection:
|
|
return b.processStarRecord(did, record)
|
|
default:
|
|
return fmt.Errorf("unsupported collection: %s", collection)
|
|
}
|
|
}
|
|
|
|
// processManifestRecord processes a manifest record
|
|
func (b *BackfillWorker) processManifestRecord(did string, record *atproto.Record) error {
|
|
var manifestRecord atproto.ManifestRecord
|
|
if err := json.Unmarshal(record.Value, &manifestRecord); err != nil {
|
|
return fmt.Errorf("failed to unmarshal manifest: %w", err)
|
|
}
|
|
|
|
// Extract OCI annotations from manifest
|
|
var title, description, sourceURL, documentationURL, licenses, iconURL string
|
|
if manifestRecord.Annotations != nil {
|
|
title = manifestRecord.Annotations["org.opencontainers.image.title"]
|
|
description = manifestRecord.Annotations["org.opencontainers.image.description"]
|
|
sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"]
|
|
documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"]
|
|
licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"]
|
|
iconURL = manifestRecord.Annotations["io.atcr.icon"]
|
|
}
|
|
|
|
// Insert manifest
|
|
manifestID, err := db.InsertManifest(b.db, &db.Manifest{
|
|
DID: did,
|
|
Repository: manifestRecord.Repository,
|
|
Digest: manifestRecord.Digest,
|
|
MediaType: manifestRecord.MediaType,
|
|
SchemaVersion: manifestRecord.SchemaVersion,
|
|
ConfigDigest: manifestRecord.Config.Digest,
|
|
ConfigSize: manifestRecord.Config.Size,
|
|
HoldEndpoint: manifestRecord.HoldEndpoint,
|
|
CreatedAt: manifestRecord.CreatedAt,
|
|
Title: title,
|
|
Description: description,
|
|
SourceURL: sourceURL,
|
|
DocumentationURL: documentationURL,
|
|
Licenses: licenses,
|
|
IconURL: iconURL,
|
|
})
|
|
if err != nil {
|
|
// Skip if already exists
|
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to insert manifest: %w", err)
|
|
}
|
|
|
|
// Insert layers
|
|
for i, layer := range manifestRecord.Layers {
|
|
if err := db.InsertLayer(b.db, &db.Layer{
|
|
ManifestID: manifestID,
|
|
Digest: layer.Digest,
|
|
MediaType: layer.MediaType,
|
|
Size: layer.Size,
|
|
LayerIndex: i,
|
|
}); err != nil {
|
|
// Continue on error - layer might already exist
|
|
continue
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// processTagRecord processes a tag record
|
|
func (b *BackfillWorker) processTagRecord(did string, record *atproto.Record) error {
|
|
var tagRecord atproto.TagRecord
|
|
if err := json.Unmarshal(record.Value, &tagRecord); err != nil {
|
|
return fmt.Errorf("failed to unmarshal tag: %w", err)
|
|
}
|
|
|
|
// Insert or update tag
|
|
return db.UpsertTag(b.db, &db.Tag{
|
|
DID: did,
|
|
Repository: tagRecord.Repository,
|
|
Tag: tagRecord.Tag,
|
|
Digest: tagRecord.ManifestDigest,
|
|
CreatedAt: tagRecord.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
// processStarRecord processes a star record
|
|
func (b *BackfillWorker) processStarRecord(did string, record *atproto.Record) error {
|
|
var starRecord atproto.StarRecord
|
|
if err := json.Unmarshal(record.Value, &starRecord); err != nil {
|
|
return fmt.Errorf("failed to unmarshal star: %w", err)
|
|
}
|
|
|
|
// Upsert the star record (idempotent - won't duplicate)
|
|
// The DID here is the starrer (user who starred)
|
|
// The subject contains the owner DID and repository
|
|
// Star count will be calculated on demand from the stars table
|
|
return db.UpsertStar(b.db, did, starRecord.Subject.DID, starRecord.Subject.Repository, starRecord.CreatedAt)
|
|
}
|
|
|
|
// ensureUser resolves and upserts a user by DID
|
|
func (b *BackfillWorker) ensureUser(ctx context.Context, did string) error {
|
|
// Check if user already exists
|
|
existingUser, err := db.GetUserByDID(b.db, did)
|
|
if err == nil && existingUser != nil {
|
|
// Update last seen
|
|
existingUser.LastSeen = time.Now()
|
|
return db.UpsertUser(b.db, existingUser)
|
|
}
|
|
|
|
// Resolve DID to get handle and PDS endpoint
|
|
didParsed, err := syntax.ParseDID(did)
|
|
if err != nil {
|
|
// Fallback: use DID as handle
|
|
user := &db.User{
|
|
DID: did,
|
|
Handle: did,
|
|
PDSEndpoint: "https://bsky.social",
|
|
LastSeen: time.Now(),
|
|
}
|
|
return db.UpsertUser(b.db, user)
|
|
}
|
|
|
|
ident, err := b.directory.LookupDID(ctx, didParsed)
|
|
if err != nil {
|
|
// Fallback: use DID as handle
|
|
user := &db.User{
|
|
DID: did,
|
|
Handle: did,
|
|
PDSEndpoint: "https://bsky.social",
|
|
LastSeen: time.Now(),
|
|
}
|
|
return db.UpsertUser(b.db, user)
|
|
}
|
|
|
|
resolvedDID := ident.DID.String()
|
|
handle := ident.Handle.String()
|
|
pdsEndpoint := ident.PDSEndpoint()
|
|
|
|
// If handle is invalid or PDS is missing, use defaults
|
|
if handle == "handle.invalid" || handle == "" {
|
|
handle = resolvedDID
|
|
}
|
|
if pdsEndpoint == "" {
|
|
pdsEndpoint = "https://bsky.social"
|
|
}
|
|
|
|
// Fetch user's Bluesky profile (including avatar)
|
|
// Use public Bluesky AppView API (doesn't require auth for public profiles)
|
|
avatar := ""
|
|
publicClient := atproto.NewClient("https://public.api.bsky.app", "", "")
|
|
profile, err := publicClient.GetActorProfile(ctx, resolvedDID)
|
|
if err != nil {
|
|
fmt.Printf("WARNING [backfill]: Failed to fetch profile for DID %s: %v\n", resolvedDID, err)
|
|
// Continue without avatar
|
|
} else {
|
|
avatar = profile.Avatar
|
|
}
|
|
|
|
// Upsert to database
|
|
user := &db.User{
|
|
DID: resolvedDID,
|
|
Handle: handle,
|
|
PDSEndpoint: pdsEndpoint,
|
|
Avatar: avatar,
|
|
LastSeen: time.Now(),
|
|
}
|
|
|
|
return db.UpsertUser(b.db, user)
|
|
}
|