tangled
alpha
login
or
join now
evan.jarrett.net
/
at-container-registry
66
fork
atom
A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
66
fork
atom
overview
issues
1
pulls
pipelines
add relay-compare tool
evan.jarrett.net
2 weeks ago
5249c9ea
2b9ea997
verified
This commit was signed with the committer's
known signature
.
evan.jarrett.net
SSH Key Fingerprint:
SHA256:bznk0uVPp7XFOl67P0uTM1pCjf2A4ojeP/lsUE7uauQ=
0/2
lint.yaml
failed
5min 2s
tests.yml
failed
5min 2s
+292
1 changed file
expand all
collapse all
unified
split
cmd
relay-compare
main.go
+292
cmd/relay-compare/main.go
···
1
1
+
// relay-compare compares ATProto relays by querying listReposByCollection
2
2
+
// for all io.atcr.* record types and showing what's missing from each relay.
3
3
+
//
4
4
+
// Usage:
5
5
+
//
6
6
+
// go run ./cmd/relay-compare https://relay1.us-east.bsky.network https://relay1.us-west.bsky.network
7
7
+
package main
8
8
+
9
9
+
import (
10
10
+
"context"
11
11
+
"flag"
12
12
+
"fmt"
13
13
+
"net/url"
14
14
+
"os"
15
15
+
"sort"
16
16
+
"strings"
17
17
+
"sync"
18
18
+
"time"
19
19
+
20
20
+
"atcr.io/pkg/atproto"
21
21
+
)
22
22
+
23
23
+
// ANSI color codes (disabled via --no-color or NO_COLOR env)
24
24
+
var (
25
25
+
cRed = "\033[31m"
26
26
+
cGreen = "\033[32m"
27
27
+
cYellow = "\033[33m"
28
28
+
cCyan = "\033[36m"
29
29
+
cBold = "\033[1m"
30
30
+
cDim = "\033[2m"
31
31
+
cReset = "\033[0m"
32
32
+
)
33
33
+
34
34
+
func disableColors() {
35
35
+
cRed, cGreen, cYellow, cCyan, cBold, cDim, cReset = "", "", "", "", "", "", ""
36
36
+
}
37
37
+
38
38
+
// All io.atcr.* collections to compare
39
39
+
var allCollections = []string{
40
40
+
atproto.ManifestCollection, // io.atcr.manifest
41
41
+
atproto.TagCollection, // io.atcr.tag
42
42
+
atproto.SailorProfileCollection, // io.atcr.sailor.profile
43
43
+
atproto.StarCollection, // io.atcr.sailor.star
44
44
+
atproto.SailorWebhookCollection, // io.atcr.sailor.webhook
45
45
+
atproto.RepoPageCollection, // io.atcr.repo.page
46
46
+
atproto.CaptainCollection, // io.atcr.hold.captain
47
47
+
atproto.CrewCollection, // io.atcr.hold.crew
48
48
+
atproto.LayerCollection, // io.atcr.hold.layer
49
49
+
atproto.StatsCollection, // io.atcr.hold.stats
50
50
+
atproto.ScanCollection, // io.atcr.hold.scan
51
51
+
atproto.WebhookCollection, // io.atcr.hold.webhook
52
52
+
}
53
53
+
54
54
+
type summaryRow struct {
55
55
+
collection string
56
56
+
counts []int
57
57
+
status string // "sync", "diff", "error"
58
58
+
diffCount int
59
59
+
}
60
60
+
61
61
+
func main() {
62
62
+
noColor := flag.Bool("no-color", false, "disable colored output")
63
63
+
collection := flag.String("collection", "", "compare only this collection")
64
64
+
timeout := flag.Duration("timeout", 2*time.Minute, "timeout for all relay queries")
65
65
+
flag.Usage = func() {
66
66
+
fmt.Fprintf(os.Stderr, "Compare ATProto relays by querying listReposByCollection for io.atcr.* records.\n\n")
67
67
+
fmt.Fprintf(os.Stderr, "Usage:\n relay-compare [flags] <relay-url> <relay-url> [relay-url...]\n\n")
68
68
+
fmt.Fprintf(os.Stderr, "Example:\n")
69
69
+
fmt.Fprintf(os.Stderr, " go run ./cmd/relay-compare https://relay1.us-east.bsky.network https://relay1.us-west.bsky.network\n\n")
70
70
+
fmt.Fprintf(os.Stderr, "Flags:\n")
71
71
+
flag.PrintDefaults()
72
72
+
}
73
73
+
flag.Parse()
74
74
+
75
75
+
if *noColor || os.Getenv("NO_COLOR") != "" {
76
76
+
disableColors()
77
77
+
}
78
78
+
79
79
+
relays := flag.Args()
80
80
+
if len(relays) < 2 {
81
81
+
flag.Usage()
82
82
+
os.Exit(1)
83
83
+
}
84
84
+
85
85
+
for i, r := range relays {
86
86
+
relays[i] = strings.TrimRight(r, "/")
87
87
+
}
88
88
+
89
89
+
cols := allCollections
90
90
+
if *collection != "" {
91
91
+
cols = []string{*collection}
92
92
+
}
93
93
+
94
94
+
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
95
95
+
defer cancel()
96
96
+
97
97
+
// Short display names for each relay
98
98
+
names := make([]string, len(relays))
99
99
+
maxNameLen := 0
100
100
+
for i, r := range relays {
101
101
+
names[i] = shortName(r)
102
102
+
if len(names[i]) > maxNameLen {
103
103
+
maxNameLen = len(names[i])
104
104
+
}
105
105
+
}
106
106
+
107
107
+
fmt.Printf("%sFetching %d collections from %d relays...%s\n", cDim, len(cols), len(relays), cReset)
108
108
+
109
109
+
// Fetch all data in parallel: every (collection, relay) pair concurrently
110
110
+
type key struct{ col, relay string }
111
111
+
type fetchResult struct {
112
112
+
dids map[string]struct{}
113
113
+
err error
114
114
+
}
115
115
+
allResults := make(map[key]fetchResult)
116
116
+
var mu sync.Mutex
117
117
+
var wg sync.WaitGroup
118
118
+
119
119
+
for _, col := range cols {
120
120
+
for _, relay := range relays {
121
121
+
wg.Add(1)
122
122
+
go func(col, relay string) {
123
123
+
defer wg.Done()
124
124
+
dids, err := fetchAllDIDs(ctx, relay, col)
125
125
+
mu.Lock()
126
126
+
allResults[key{col, relay}] = fetchResult{dids, err}
127
127
+
mu.Unlock()
128
128
+
}(col, relay)
129
129
+
}
130
130
+
}
131
131
+
wg.Wait()
132
132
+
133
133
+
// Display per-collection diffs and collect summary
134
134
+
var summary []summaryRow
135
135
+
totalMissing := 0
136
136
+
137
137
+
for _, col := range cols {
138
138
+
fmt.Printf("\n%s%s━━━ %s ━━━%s\n", cBold, cCyan, col, cReset)
139
139
+
140
140
+
row := summaryRow{collection: col, counts: make([]int, len(relays))}
141
141
+
hasError := false
142
142
+
143
143
+
// Show counts per relay
144
144
+
for ri, relay := range relays {
145
145
+
r := allResults[key{col, relay}]
146
146
+
if r.err != nil {
147
147
+
hasError = true
148
148
+
fmt.Printf(" %-*s %s%serror%s: %v\n", maxNameLen, names[ri], cBold, cRed, cReset, r.err)
149
149
+
} else {
150
150
+
row.counts[ri] = len(r.dids)
151
151
+
fmt.Printf(" %-*s %s%d%s DIDs\n", maxNameLen, names[ri], cBold, len(r.dids), cReset)
152
152
+
}
153
153
+
}
154
154
+
155
155
+
if hasError {
156
156
+
row.status = "error"
157
157
+
summary = append(summary, row)
158
158
+
continue
159
159
+
}
160
160
+
161
161
+
// Build union of all DIDs across relays
162
162
+
union := make(map[string]struct{})
163
163
+
for _, relay := range relays {
164
164
+
for did := range allResults[key{col, relay}].dids {
165
165
+
union[did] = struct{}{}
166
166
+
}
167
167
+
}
168
168
+
169
169
+
// For each relay, show what it's missing
170
170
+
inSync := true
171
171
+
for ri, relay := range relays {
172
172
+
var missing []string
173
173
+
for did := range union {
174
174
+
if _, ok := allResults[key{col, relay}].dids[did]; !ok {
175
175
+
missing = append(missing, did)
176
176
+
}
177
177
+
}
178
178
+
if len(missing) == 0 {
179
179
+
continue
180
180
+
}
181
181
+
182
182
+
inSync = false
183
183
+
totalMissing += len(missing)
184
184
+
row.diffCount += len(missing)
185
185
+
sort.Strings(missing)
186
186
+
187
187
+
fmt.Printf("\n %sMissing from %s (%d):%s\n", cRed, names[ri], len(missing), cReset)
188
188
+
for _, did := range missing {
189
189
+
fmt.Printf(" %s- %s%s\n", cRed, did, cReset)
190
190
+
}
191
191
+
}
192
192
+
193
193
+
if inSync {
194
194
+
fmt.Printf(" %s✓ in sync%s\n", cGreen, cReset)
195
195
+
row.status = "sync"
196
196
+
} else {
197
197
+
row.status = "diff"
198
198
+
}
199
199
+
summary = append(summary, row)
200
200
+
}
201
201
+
202
202
+
// Summary table
203
203
+
printSummary(summary, names, maxNameLen, totalMissing)
204
204
+
}
205
205
+
206
206
+
func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing int) {
207
207
+
fmt.Printf("\n%s%s━━━ Summary ━━━%s\n\n", cBold, cCyan, cReset)
208
208
+
209
209
+
colW := 28
210
210
+
relayW := maxNameLen + 2
211
211
+
if relayW < 8 {
212
212
+
relayW = 8
213
213
+
}
214
214
+
215
215
+
// Header
216
216
+
fmt.Printf(" %-*s", colW, "Collection")
217
217
+
for _, name := range names {
218
218
+
fmt.Printf(" %*s", relayW, name)
219
219
+
}
220
220
+
fmt.Printf(" Status\n")
221
221
+
222
222
+
// Separator
223
223
+
fmt.Printf(" %s", strings.Repeat("─", colW))
224
224
+
for range names {
225
225
+
fmt.Printf(" %s", strings.Repeat("─", relayW))
226
226
+
}
227
227
+
fmt.Printf(" %s\n", strings.Repeat("─", 14))
228
228
+
229
229
+
// Data rows
230
230
+
for _, row := range rows {
231
231
+
fmt.Printf(" %-*s", colW, row.collection)
232
232
+
for _, c := range row.counts {
233
233
+
switch row.status {
234
234
+
case "error":
235
235
+
fmt.Printf(" %*s", relayW, fmt.Sprintf("%s—%s", cDim, cReset))
236
236
+
default:
237
237
+
fmt.Printf(" %*d", relayW, c)
238
238
+
}
239
239
+
}
240
240
+
switch row.status {
241
241
+
case "sync":
242
242
+
fmt.Printf(" %s✓ in sync%s", cGreen, cReset)
243
243
+
case "diff":
244
244
+
fmt.Printf(" %s≠ %d missing%s", cYellow, row.diffCount, cReset)
245
245
+
case "error":
246
246
+
fmt.Printf(" %s✗ error%s", cRed, cReset)
247
247
+
}
248
248
+
fmt.Println()
249
249
+
}
250
250
+
251
251
+
// Footer
252
252
+
fmt.Println()
253
253
+
if totalMissing > 0 {
254
254
+
fmt.Printf("%s%d total missing DID-collection pairs across relays%s\n", cYellow, totalMissing, cReset)
255
255
+
} else {
256
256
+
fmt.Printf("%s✓ All relays fully in sync%s\n", cGreen, cReset)
257
257
+
}
258
258
+
}
259
259
+
260
260
+
// fetchAllDIDs paginates through listReposByCollection to collect all DIDs.
261
261
+
func fetchAllDIDs(ctx context.Context, relay, collection string) (map[string]struct{}, error) {
262
262
+
client := atproto.NewClient(relay, "", "")
263
263
+
dids := make(map[string]struct{})
264
264
+
var cursor string
265
265
+
266
266
+
for {
267
267
+
result, err := client.ListReposByCollection(ctx, collection, 1000, cursor)
268
268
+
if err != nil {
269
269
+
return dids, err
270
270
+
}
271
271
+
272
272
+
for _, repo := range result.Repos {
273
273
+
dids[repo.DID] = struct{}{}
274
274
+
}
275
275
+
276
276
+
if result.Cursor == "" {
277
277
+
break
278
278
+
}
279
279
+
cursor = result.Cursor
280
280
+
}
281
281
+
282
282
+
return dids, nil
283
283
+
}
284
284
+
285
285
+
// shortName extracts the hostname from a relay URL for display.
286
286
+
func shortName(relayURL string) string {
287
287
+
u, err := url.Parse(relayURL)
288
288
+
if err != nil {
289
289
+
return relayURL
290
290
+
}
291
291
+
return u.Hostname()
292
292
+
}