Add collectiondir (`com.atproto.sync.listReposByCollection`) endpoint support to backfill on startupUnverified
e7fd77c parent: fd9ed81 modified
.env.example +1 -0 | @@ -4,6 +4,7 @@ GLEAN_JETSTREAM=wss://jetstream.glean.at | ||
| 4 | 4 | GLEAN_PLC_URL=https://didplc.glean.at |
| 5 | 5 | GLEAN_SYNC_INTERVAL=1h |
| 6 | 6 | GLEAN_CLUSTER_INTERVAL=6h |
| 7 | +GLEAN_COLLECTION_DIR_URL=https://lightrail.microcosm.blue/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription | |
| 7 | 8 | # Leave empty for localhost OAuth (development) |
| 8 | 9 | # GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata |
| 9 | 10 | # GLEAN_OAUTH_REDIRECT_URL=https://glean.at/auth/callback |
| @@ -4,6 +4,7 @@ GLEAN_JETSTREAM=wss://jetstream.glean.at | |||
| 4 | GLEAN_PLC_URL=https://didplc.glean.at | 4 | GLEAN_PLC_URL=https://didplc.glean.at |
| 5 | GLEAN_SYNC_INTERVAL=1h | 5 | GLEAN_SYNC_INTERVAL=1h |
| 6 | GLEAN_CLUSTER_INTERVAL=6h | 6 | GLEAN_CLUSTER_INTERVAL=6h |
| 7 | +GLEAN_COLLECTION_DIR_URL=https://lightrail.microcosm.blue/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription | ||
| 7 | # Leave empty for localhost OAuth (development) | 8 | # Leave empty for localhost OAuth (development) |
| 8 | # GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata | 9 | # GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata |
| 9 | # GLEAN_OAUTH_REDIRECT_URL=https://glean.at/auth/callback | 10 | # GLEAN_OAUTH_REDIRECT_URL=https://glean.at/auth/callback |
modified
internal/atproto/client.go +4 -0 | @@ -17,6 +17,10 @@ func NewClient(api *atclient.APIClient) *Client { | ||
| 17 | 17 | return &Client{api: api} |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | +func NewUnauthenticatedClient(pdsURL string) *Client { | |
| 21 | + return &Client{api: atclient.NewAPIClient(pdsURL)} | |
| 22 | +} | |
| 23 | + | |
| 20 | 24 | func (c *Client) CreateRecord(ctx context.Context, did, collection string, record any) (string, string, error) { |
| 21 | 25 | input := map[string]any{ |
| 22 | 26 | "repo": did, |
| @@ -17,6 +17,10 @@ func NewClient(api *atclient.APIClient) *Client { | |||
| 17 | return &Client{api: api} | 17 | return &Client{api: api} |
| 18 | } | 18 | } |
| 19 | 19 | ||
| 20 | +func NewUnauthenticatedClient(pdsURL string) *Client { | ||
| 21 | + return &Client{api: atclient.NewAPIClient(pdsURL)} | ||
| 22 | +} | ||
| 23 | + | ||
| 20 | func (c *Client) CreateRecord(ctx context.Context, did, collection string, record any) (string, string, error) { | 24 | func (c *Client) CreateRecord(ctx context.Context, did, collection string, record any) (string, string, error) { |
| 21 | input := map[string]any{ | 25 | input := map[string]any{ |
| 22 | "repo": did, | 26 | "repo": did, |
added
internal/atproto/collectiondir.go +67 -0 | new file mode 100644 | ||
| @@ -0,0 +1,67 @@ | ||
| 1 | +package atproto | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "fmt" | |
| 7 | + "io" | |
| 8 | + "net/http" | |
| 9 | + "time" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// https://github.com/bluesky-social/indigo/tree/main/cmd/collectiondir | |
| 13 | +type listReposByCollectionResponse struct { | |
| 14 | + Repos []struct { | |
| 15 | + DID string `json:"did"` | |
| 16 | + } `json:"repos"` | |
| 17 | + Cursor string `json:"cursor"` | |
| 18 | +} | |
| 19 | + | |
| 20 | +func FetchSubscriberDIDs(ctx context.Context, baseURL string) ([]string, error) { | |
| 21 | + var allDIDs []string | |
| 22 | + cursor := "" | |
| 23 | + client := &http.Client{Timeout: 30 * time.Second} | |
| 24 | + | |
| 25 | + for { | |
| 26 | + url := baseURL | |
| 27 | + if cursor != "" { | |
| 28 | + url = fmt.Sprintf("%s&cursor=%s", baseURL, cursor) | |
| 29 | + } | |
| 30 | + | |
| 31 | + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |
| 32 | + if err != nil { | |
| 33 | + return nil, fmt.Errorf("building request: %w", err) | |
| 34 | + } | |
| 35 | + | |
| 36 | + resp, err := client.Do(req) | |
| 37 | + if err != nil { | |
| 38 | + return nil, fmt.Errorf("fetching collection dir: %w", err) | |
| 39 | + } | |
| 40 | + | |
| 41 | + body, err := io.ReadAll(resp.Body) | |
| 42 | + resp.Body.Close() | |
| 43 | + if err != nil { | |
| 44 | + return nil, fmt.Errorf("reading response: %w", err) | |
| 45 | + } | |
| 46 | + | |
| 47 | + if resp.StatusCode != http.StatusOK { | |
| 48 | + return nil, fmt.Errorf("collection dir returned %d: %s", resp.StatusCode, string(body)) | |
| 49 | + } | |
| 50 | + | |
| 51 | + var result listReposByCollectionResponse | |
| 52 | + if err := json.Unmarshal(body, &result); err != nil { | |
| 53 | + return nil, fmt.Errorf("parsing response: %w", err) | |
| 54 | + } | |
| 55 | + | |
| 56 | + for _, r := range result.Repos { | |
| 57 | + allDIDs = append(allDIDs, r.DID) | |
| 58 | + } | |
| 59 | + | |
| 60 | + if result.Cursor == "" || len(result.Repos) == 0 { | |
| 61 | + break | |
| 62 | + } | |
| 63 | + cursor = result.Cursor | |
| 64 | + } | |
| 65 | + | |
| 66 | + return allDIDs, nil | |
| 67 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,67 @@ | |||
| 1 | +package atproto | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "fmt" | ||
| 7 | + "io" | ||
| 8 | + "net/http" | ||
| 9 | + "time" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +// https://github.com/bluesky-social/indigo/tree/main/cmd/collectiondir | ||
| 13 | +type listReposByCollectionResponse struct { | ||
| 14 | + Repos []struct { | ||
| 15 | + DID string `json:"did"` | ||
| 16 | + } `json:"repos"` | ||
| 17 | + Cursor string `json:"cursor"` | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +func FetchSubscriberDIDs(ctx context.Context, baseURL string) ([]string, error) { | ||
| 21 | + var allDIDs []string | ||
| 22 | + cursor := "" | ||
| 23 | + client := &http.Client{Timeout: 30 * time.Second} | ||
| 24 | + | ||
| 25 | + for { | ||
| 26 | + url := baseURL | ||
| 27 | + if cursor != "" { | ||
| 28 | + url = fmt.Sprintf("%s&cursor=%s", baseURL, cursor) | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | ||
| 32 | + if err != nil { | ||
| 33 | + return nil, fmt.Errorf("building request: %w", err) | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + resp, err := client.Do(req) | ||
| 37 | + if err != nil { | ||
| 38 | + return nil, fmt.Errorf("fetching collection dir: %w", err) | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + body, err := io.ReadAll(resp.Body) | ||
| 42 | + resp.Body.Close() | ||
| 43 | + if err != nil { | ||
| 44 | + return nil, fmt.Errorf("reading response: %w", err) | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + if resp.StatusCode != http.StatusOK { | ||
| 48 | + return nil, fmt.Errorf("collection dir returned %d: %s", resp.StatusCode, string(body)) | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + var result listReposByCollectionResponse | ||
| 52 | + if err := json.Unmarshal(body, &result); err != nil { | ||
| 53 | + return nil, fmt.Errorf("parsing response: %w", err) | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + for _, r := range result.Repos { | ||
| 57 | + allDIDs = append(allDIDs, r.DID) | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + if result.Cursor == "" || len(result.Repos) == 0 { | ||
| 61 | + break | ||
| 62 | + } | ||
| 63 | + cursor = result.Cursor | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + return allDIDs, nil | ||
| 67 | +} | ||
added
internal/atproto/collectiondir_test.go +97 -0 | new file mode 100644 | ||
| @@ -0,0 +1,97 @@ | ||
| 1 | +package atproto | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "net/http" | |
| 7 | + "net/http/httptest" | |
| 8 | + "testing" | |
| 9 | + | |
| 10 | + "gotest.tools/v3/assert" | |
| 11 | +) | |
| 12 | + | |
| 13 | +func TestFetchSubscriberDIDs(t *testing.T) { | |
| 14 | + page1 := struct { | |
| 15 | + Repos []struct { | |
| 16 | + DID string `json:"did"` | |
| 17 | + } `json:"repos"` | |
| 18 | + Cursor string `json:"cursor"` | |
| 19 | + }{ | |
| 20 | + Repos: []struct { | |
| 21 | + DID string `json:"did"` | |
| 22 | + }{ | |
| 23 | + {DID: "did:plc:aaa"}, | |
| 24 | + {DID: "did:plc:bbb"}, | |
| 25 | + }, | |
| 26 | + Cursor: "nextpage", | |
| 27 | + } | |
| 28 | + page2 := struct { | |
| 29 | + Repos []struct { | |
| 30 | + DID string `json:"did"` | |
| 31 | + } `json:"repos"` | |
| 32 | + Cursor string `json:"cursor"` | |
| 33 | + }{ | |
| 34 | + Repos: []struct { | |
| 35 | + DID string `json:"did"` | |
| 36 | + }{ | |
| 37 | + {DID: "did:plc:ccc"}, | |
| 38 | + }, | |
| 39 | + Cursor: "", | |
| 40 | + } | |
| 41 | + | |
| 42 | + callCount := 0 | |
| 43 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 44 | + callCount++ | |
| 45 | + assert.Equal(t, r.URL.Query().Get("collection"), "at.glean.subscription") | |
| 46 | + | |
| 47 | + w.Header().Set("Content-Type", "application/json") | |
| 48 | + if callCount == 1 { | |
| 49 | + assert.Equal(t, r.URL.Query().Get("cursor"), "") | |
| 50 | + json.NewEncoder(w).Encode(page1) | |
| 51 | + } else { | |
| 52 | + assert.Equal(t, r.URL.Query().Get("cursor"), "nextpage") | |
| 53 | + json.NewEncoder(w).Encode(page2) | |
| 54 | + } | |
| 55 | + })) | |
| 56 | + defer server.Close() | |
| 57 | + | |
| 58 | + url := server.URL + "/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription" | |
| 59 | + dids, err := FetchSubscriberDIDs(context.Background(), url) | |
| 60 | + assert.NilError(t, err) | |
| 61 | + assert.Equal(t, callCount, 2) | |
| 62 | + assert.DeepEqual(t, dids, []string{"did:plc:aaa", "did:plc:bbb", "did:plc:ccc"}) | |
| 63 | +} | |
| 64 | + | |
| 65 | +func TestFetchSubscriberDIDs_EmptyResponse(t *testing.T) { | |
| 66 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 67 | + w.Header().Set("Content-Type", "application/json") | |
| 68 | + json.NewEncoder(w).Encode(map[string]any{"repos": []any{}}) | |
| 69 | + })) | |
| 70 | + defer server.Close() | |
| 71 | + | |
| 72 | + url := server.URL + "/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription" | |
| 73 | + dids, err := FetchSubscriberDIDs(context.Background(), url) | |
| 74 | + assert.NilError(t, err) | |
| 75 | + assert.Equal(t, len(dids), 0) | |
| 76 | +} | |
| 77 | + | |
| 78 | +func TestFetchSubscriberDIDs_ServerError(t *testing.T) { | |
| 79 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 80 | + w.WriteHeader(http.StatusInternalServerError) | |
| 81 | + w.Write([]byte("internal error")) | |
| 82 | + })) | |
| 83 | + defer server.Close() | |
| 84 | + | |
| 85 | + url := server.URL + "/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription" | |
| 86 | + _, err := FetchSubscriberDIDs(context.Background(), url) | |
| 87 | + assert.Assert(t, err != nil) | |
| 88 | +} | |
| 89 | + | |
| 90 | +func TestFetchSubscriberDIDs_CancelledContext(t *testing.T) { | |
| 91 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 92 | + cancel() | |
| 93 | + | |
| 94 | + url := "http://localhost/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription" | |
| 95 | + _, err := FetchSubscriberDIDs(ctx, url) | |
| 96 | + assert.Assert(t, err != nil) | |
| 97 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,97 @@ | |||
| 1 | +package atproto | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "net/http" | ||
| 7 | + "net/http/httptest" | ||
| 8 | + "testing" | ||
| 9 | + | ||
| 10 | + "gotest.tools/v3/assert" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +func TestFetchSubscriberDIDs(t *testing.T) { | ||
| 14 | + page1 := struct { | ||
| 15 | + Repos []struct { | ||
| 16 | + DID string `json:"did"` | ||
| 17 | + } `json:"repos"` | ||
| 18 | + Cursor string `json:"cursor"` | ||
| 19 | + }{ | ||
| 20 | + Repos: []struct { | ||
| 21 | + DID string `json:"did"` | ||
| 22 | + }{ | ||
| 23 | + {DID: "did:plc:aaa"}, | ||
| 24 | + {DID: "did:plc:bbb"}, | ||
| 25 | + }, | ||
| 26 | + Cursor: "nextpage", | ||
| 27 | + } | ||
| 28 | + page2 := struct { | ||
| 29 | + Repos []struct { | ||
| 30 | + DID string `json:"did"` | ||
| 31 | + } `json:"repos"` | ||
| 32 | + Cursor string `json:"cursor"` | ||
| 33 | + }{ | ||
| 34 | + Repos: []struct { | ||
| 35 | + DID string `json:"did"` | ||
| 36 | + }{ | ||
| 37 | + {DID: "did:plc:ccc"}, | ||
| 38 | + }, | ||
| 39 | + Cursor: "", | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + callCount := 0 | ||
| 43 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 44 | + callCount++ | ||
| 45 | + assert.Equal(t, r.URL.Query().Get("collection"), "at.glean.subscription") | ||
| 46 | + | ||
| 47 | + w.Header().Set("Content-Type", "application/json") | ||
| 48 | + if callCount == 1 { | ||
| 49 | + assert.Equal(t, r.URL.Query().Get("cursor"), "") | ||
| 50 | + json.NewEncoder(w).Encode(page1) | ||
| 51 | + } else { | ||
| 52 | + assert.Equal(t, r.URL.Query().Get("cursor"), "nextpage") | ||
| 53 | + json.NewEncoder(w).Encode(page2) | ||
| 54 | + } | ||
| 55 | + })) | ||
| 56 | + defer server.Close() | ||
| 57 | + | ||
| 58 | + url := server.URL + "/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription" | ||
| 59 | + dids, err := FetchSubscriberDIDs(context.Background(), url) | ||
| 60 | + assert.NilError(t, err) | ||
| 61 | + assert.Equal(t, callCount, 2) | ||
| 62 | + assert.DeepEqual(t, dids, []string{"did:plc:aaa", "did:plc:bbb", "did:plc:ccc"}) | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +func TestFetchSubscriberDIDs_EmptyResponse(t *testing.T) { | ||
| 66 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 67 | + w.Header().Set("Content-Type", "application/json") | ||
| 68 | + json.NewEncoder(w).Encode(map[string]any{"repos": []any{}}) | ||
| 69 | + })) | ||
| 70 | + defer server.Close() | ||
| 71 | + | ||
| 72 | + url := server.URL + "/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription" | ||
| 73 | + dids, err := FetchSubscriberDIDs(context.Background(), url) | ||
| 74 | + assert.NilError(t, err) | ||
| 75 | + assert.Equal(t, len(dids), 0) | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +func TestFetchSubscriberDIDs_ServerError(t *testing.T) { | ||
| 79 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 80 | + w.WriteHeader(http.StatusInternalServerError) | ||
| 81 | + w.Write([]byte("internal error")) | ||
| 82 | + })) | ||
| 83 | + defer server.Close() | ||
| 84 | + | ||
| 85 | + url := server.URL + "/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription" | ||
| 86 | + _, err := FetchSubscriberDIDs(context.Background(), url) | ||
| 87 | + assert.Assert(t, err != nil) | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +func TestFetchSubscriberDIDs_CancelledContext(t *testing.T) { | ||
| 91 | + ctx, cancel := context.WithCancel(context.Background()) | ||
| 92 | + cancel() | ||
| 93 | + | ||
| 94 | + url := "http://localhost/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription" | ||
| 95 | + _, err := FetchSubscriberDIDs(ctx, url) | ||
| 96 | + assert.Assert(t, err != nil) | ||
| 97 | +} | ||
modified
internal/db/user.go +18 -0 | @@ -54,6 +54,24 @@ func (db *DB) GetUserByHandle(ctx context.Context, handle string) (*User, error) | ||
| 54 | 54 | return u, nil |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | +func (db *DB) ListUserDIDs(ctx context.Context) (map[string]bool, error) { | |
| 58 | + rows, err := db.QueryContext(ctx, `SELECT did FROM users`) | |
| 59 | + if err != nil { | |
| 60 | + return nil, err | |
| 61 | + } | |
| 62 | + defer rows.Close() | |
| 63 | + | |
| 64 | + dids := make(map[string]bool) | |
| 65 | + for rows.Next() { | |
| 66 | + var did string | |
| 67 | + if err := rows.Scan(&did); err != nil { | |
| 68 | + return nil, err | |
| 69 | + } | |
| 70 | + dids[did] = true | |
| 71 | + } | |
| 72 | + return dids, rows.Err() | |
| 73 | +} | |
| 74 | + | |
| 57 | 75 | func (db *DB) ListUsers(ctx context.Context) ([]*User, error) { |
| 58 | 76 | rows, err := db.QueryContext(ctx, ` |
| 59 | 77 | SELECT did, handle, display_name, avatar_url, indexed_at, updated_at |
| @@ -54,6 +54,24 @@ func (db *DB) GetUserByHandle(ctx context.Context, handle string) (*User, error) | |||
| 54 | return u, nil | 54 | return u, nil |
| 55 | } | 55 | } |
| 56 | 56 | ||
| 57 | +func (db *DB) ListUserDIDs(ctx context.Context) (map[string]bool, error) { | ||
| 58 | + rows, err := db.QueryContext(ctx, `SELECT did FROM users`) | ||
| 59 | + if err != nil { | ||
| 60 | + return nil, err | ||
| 61 | + } | ||
| 62 | + defer rows.Close() | ||
| 63 | + | ||
| 64 | + dids := make(map[string]bool) | ||
| 65 | + for rows.Next() { | ||
| 66 | + var did string | ||
| 67 | + if err := rows.Scan(&did); err != nil { | ||
| 68 | + return nil, err | ||
| 69 | + } | ||
| 70 | + dids[did] = true | ||
| 71 | + } | ||
| 72 | + return dids, rows.Err() | ||
| 73 | +} | ||
| 74 | + | ||
| 57 | func (db *DB) ListUsers(ctx context.Context) ([]*User, error) { | 75 | func (db *DB) ListUsers(ctx context.Context) ([]*User, error) { |
| 58 | rows, err := db.QueryContext(ctx, ` | 76 | rows, err := db.QueryContext(ctx, ` |
| 59 | SELECT did, handle, display_name, avatar_url, indexed_at, updated_at | 77 | SELECT did, handle, display_name, avatar_url, indexed_at, updated_at |
modified
internal/server/server.go +62 -0 | @@ -440,6 +440,68 @@ func (s *Server) PeriodicSync(ctx context.Context, interval time.Duration) { | ||
| 440 | 440 | } |
| 441 | 441 | } |
| 442 | 442 | |
| 443 | +func (s *Server) BackfillFromCollectionDir(ctx context.Context, collectionDirURL string) { | |
| 444 | + if collectionDirURL == "" { | |
| 445 | + return | |
| 446 | + } | |
| 447 | + | |
| 448 | + s.logger.Info("backfilling from collection directory", "url", collectionDirURL) | |
| 449 | + | |
| 450 | + dids, err := atproto.FetchSubscriberDIDs(ctx, collectionDirURL) | |
| 451 | + if err != nil { | |
| 452 | + s.logger.Error("failed to fetch subscriber DIDs", "error", err) | |
| 453 | + return | |
| 454 | + } | |
| 455 | + | |
| 456 | + existing, err := s.db.ListUserDIDs(ctx) | |
| 457 | + if err != nil { | |
| 458 | + s.logger.Error("failed to list existing users", "error", err) | |
| 459 | + return | |
| 460 | + } | |
| 461 | + | |
| 462 | + var missing []string | |
| 463 | + for _, did := range dids { | |
| 464 | + if !existing[did] { | |
| 465 | + missing = append(missing, did) | |
| 466 | + } | |
| 467 | + } | |
| 468 | + | |
| 469 | + s.logger.Info("collection directory backfill", "total", len(dids), "missing", len(missing)) | |
| 470 | + | |
| 471 | + for _, did := range missing { | |
| 472 | + if ctx.Err() != nil { | |
| 473 | + return | |
| 474 | + } | |
| 475 | + | |
| 476 | + handle := did | |
| 477 | + if ident, err := atproto.ResolveIdentity(ctx, did); err == nil { | |
| 478 | + handle = ident.Handle.String() | |
| 479 | + } | |
| 480 | + | |
| 481 | + if _, err := s.db.CreateUser(ctx, did, handle, "", ""); err != nil { | |
| 482 | + s.logger.Error("failed to create user during backfill", "error", err, "did", did) | |
| 483 | + continue | |
| 484 | + } | |
| 485 | + | |
| 486 | + pdsURL, err := atproto.ResolvePDSEndpoint(ctx, did) | |
| 487 | + if err != nil { | |
| 488 | + s.logger.Error("failed to resolve PDS for backfill", "error", err, "did", did) | |
| 489 | + continue | |
| 490 | + } | |
| 491 | + | |
| 492 | + client := atproto.NewUnauthenticatedClient(pdsURL) | |
| 493 | + sync := atproto.NewSync(s.db, client, s.logger) | |
| 494 | + if err := sync.Run(ctx, did); err != nil { | |
| 495 | + s.logger.Error("backfill sync failed", "error", err, "did", did) | |
| 496 | + } | |
| 497 | + | |
| 498 | + s.refreshUserFeeds(ctx, did) | |
| 499 | + s.engine.ComputeForUser(ctx, did) | |
| 500 | + } | |
| 501 | + | |
| 502 | + s.logger.Info("collection directory backfill complete") | |
| 503 | +} | |
| 504 | + | |
| 443 | 505 | func (s *Server) runSyncAll(ctx context.Context) { |
| 444 | 506 | users, err := s.db.ListUsers(ctx) |
| 445 | 507 | if err != nil { |
| @@ -440,6 +440,68 @@ func (s *Server) PeriodicSync(ctx context.Context, interval time.Duration) { | |||
| 440 | } | 440 | } |
| 441 | } | 441 | } |
| 442 | 442 | ||
| 443 | +func (s *Server) BackfillFromCollectionDir(ctx context.Context, collectionDirURL string) { | ||
| 444 | + if collectionDirURL == "" { | ||
| 445 | + return | ||
| 446 | + } | ||
| 447 | + | ||
| 448 | + s.logger.Info("backfilling from collection directory", "url", collectionDirURL) | ||
| 449 | + | ||
| 450 | + dids, err := atproto.FetchSubscriberDIDs(ctx, collectionDirURL) | ||
| 451 | + if err != nil { | ||
| 452 | + s.logger.Error("failed to fetch subscriber DIDs", "error", err) | ||
| 453 | + return | ||
| 454 | + } | ||
| 455 | + | ||
| 456 | + existing, err := s.db.ListUserDIDs(ctx) | ||
| 457 | + if err != nil { | ||
| 458 | + s.logger.Error("failed to list existing users", "error", err) | ||
| 459 | + return | ||
| 460 | + } | ||
| 461 | + | ||
| 462 | + var missing []string | ||
| 463 | + for _, did := range dids { | ||
| 464 | + if !existing[did] { | ||
| 465 | + missing = append(missing, did) | ||
| 466 | + } | ||
| 467 | + } | ||
| 468 | + | ||
| 469 | + s.logger.Info("collection directory backfill", "total", len(dids), "missing", len(missing)) | ||
| 470 | + | ||
| 471 | + for _, did := range missing { | ||
| 472 | + if ctx.Err() != nil { | ||
| 473 | + return | ||
| 474 | + } | ||
| 475 | + | ||
| 476 | + handle := did | ||
| 477 | + if ident, err := atproto.ResolveIdentity(ctx, did); err == nil { | ||
| 478 | + handle = ident.Handle.String() | ||
| 479 | + } | ||
| 480 | + | ||
| 481 | + if _, err := s.db.CreateUser(ctx, did, handle, "", ""); err != nil { | ||
| 482 | + s.logger.Error("failed to create user during backfill", "error", err, "did", did) | ||
| 483 | + continue | ||
| 484 | + } | ||
| 485 | + | ||
| 486 | + pdsURL, err := atproto.ResolvePDSEndpoint(ctx, did) | ||
| 487 | + if err != nil { | ||
| 488 | + s.logger.Error("failed to resolve PDS for backfill", "error", err, "did", did) | ||
| 489 | + continue | ||
| 490 | + } | ||
| 491 | + | ||
| 492 | + client := atproto.NewUnauthenticatedClient(pdsURL) | ||
| 493 | + sync := atproto.NewSync(s.db, client, s.logger) | ||
| 494 | + if err := sync.Run(ctx, did); err != nil { | ||
| 495 | + s.logger.Error("backfill sync failed", "error", err, "did", did) | ||
| 496 | + } | ||
| 497 | + | ||
| 498 | + s.refreshUserFeeds(ctx, did) | ||
| 499 | + s.engine.ComputeForUser(ctx, did) | ||
| 500 | + } | ||
| 501 | + | ||
| 502 | + s.logger.Info("collection directory backfill complete") | ||
| 503 | +} | ||
| 504 | + | ||
| 443 | func (s *Server) runSyncAll(ctx context.Context) { | 505 | func (s *Server) runSyncAll(ctx context.Context) { |
| 444 | users, err := s.db.ListUsers(ctx) | 506 | users, err := s.db.ListUsers(ctx) |
| 445 | if err != nil { | 507 | if err != nil { |
modified
main.go +4 -0 | @@ -24,6 +24,7 @@ func main() { | ||
| 24 | 24 | jetstreamURL := flag.String("jetstream", envOr("GLEAN_JETSTREAM", "wss://jetstream.glean.at"), "Jetstream URL") |
| 25 | 25 | syncInterval := flag.Duration("sync-interval", envDuration("GLEAN_SYNC_INTERVAL", 1*time.Hour), "PDS sync interval") |
| 26 | 26 | clusterInterval := flag.Duration("cluster-interval", envDuration("GLEAN_CLUSTER_INTERVAL", 6*time.Hour), "cluster recomputation interval") |
| 27 | + collectionDirURL := flag.String("collection-dir", envOr("GLEAN_COLLECTION_DIR_URL", ""), "collection directory URL for startup backfill") | |
| 27 | 28 | flag.Parse() |
| 28 | 29 | |
| 29 | 30 | atproto.InitIdentity(envOr("GLEAN_PLC_URL", "https://didplc.glean.at")) |
| @@ -68,6 +69,9 @@ func main() { | ||
| 68 | 69 | go func() { |
| 69 | 70 | srv.PeriodicSync(ctx, *syncInterval) |
| 70 | 71 | }() |
| 72 | + go func() { | |
| 73 | + srv.BackfillFromCollectionDir(ctx, *collectionDirURL) | |
| 74 | + }() | |
| 71 | 75 | go func() { |
| 72 | 76 | if err := jetstream.Start(ctx); err != nil && ctx.Err() == nil { |
| 73 | 77 | logger.Error("jetstream error", "error", err) |
| @@ -24,6 +24,7 @@ func main() { | |||
| 24 | jetstreamURL := flag.String("jetstream", envOr("GLEAN_JETSTREAM", "wss://jetstream.glean.at"), "Jetstream URL") | 24 | jetstreamURL := flag.String("jetstream", envOr("GLEAN_JETSTREAM", "wss://jetstream.glean.at"), "Jetstream URL") |
| 25 | syncInterval := flag.Duration("sync-interval", envDuration("GLEAN_SYNC_INTERVAL", 1*time.Hour), "PDS sync interval") | 25 | syncInterval := flag.Duration("sync-interval", envDuration("GLEAN_SYNC_INTERVAL", 1*time.Hour), "PDS sync interval") |
| 26 | clusterInterval := flag.Duration("cluster-interval", envDuration("GLEAN_CLUSTER_INTERVAL", 6*time.Hour), "cluster recomputation interval") | 26 | clusterInterval := flag.Duration("cluster-interval", envDuration("GLEAN_CLUSTER_INTERVAL", 6*time.Hour), "cluster recomputation interval") |
| 27 | + collectionDirURL := flag.String("collection-dir", envOr("GLEAN_COLLECTION_DIR_URL", ""), "collection directory URL for startup backfill") | ||
| 27 | flag.Parse() | 28 | flag.Parse() |
| 28 | 29 | ||
| 29 | atproto.InitIdentity(envOr("GLEAN_PLC_URL", "https://didplc.glean.at")) | 30 | atproto.InitIdentity(envOr("GLEAN_PLC_URL", "https://didplc.glean.at")) |
| @@ -68,6 +69,9 @@ func main() { | |||
| 68 | go func() { | 69 | go func() { |
| 69 | srv.PeriodicSync(ctx, *syncInterval) | 70 | srv.PeriodicSync(ctx, *syncInterval) |
| 70 | }() | 71 | }() |
| 72 | + go func() { | ||
| 73 | + srv.BackfillFromCollectionDir(ctx, *collectionDirURL) | ||
| 74 | + }() | ||
| 71 | go func() { | 75 | go func() { |
| 72 | if err := jetstream.Start(ctx); err != nil && ctx.Err() == nil { | 76 | if err := jetstream.Start(ctx); err != nil && ctx.Err() == nil { |
| 73 | logger.Error("jetstream error", "error", err) | 77 | logger.Error("jetstream error", "error", err) |