feat: enhance relay descriptor handling with upsert result statuses
Kim committed
Apr 15, 2026 at 16:11 UTC
3fe3ce6c273b2d34399d226972b6acd756273ca5
2 files changed
+42
-21
frontend/src/components/ServerListView.tsx
+5
-7
@@ -869,13 +869,11 @@ export function ServerListView({
869
>
870
{relay.relayURL}
871
</a>
872
- {relayReleaseVersions[relay.relayURL] ? (
873
- <div className="flex shrink-0 items-center gap-2">
874
- <span className="rounded-full bg-background px-2.5 py-1 font-mono text-[11px] font-medium text-text-muted ring-1 ring-border">
875
- {relayReleaseVersions[relay.relayURL]}
876
- </span>
877
- </div>
878
- ) : null}
872
+ <div className="flex shrink-0 items-center gap-2">
873
+ <span className="rounded-full bg-background px-2.5 py-1 font-mono text-[11px] font-medium text-text-muted ring-1 ring-border">
874
+ {relayReleaseVersions[relay.relayURL] || "offline"}
875
+ </span>
876
+ </div>
877
</div>
878
))}
879
</div>
portal/discovery/relayset.go
+37
-14
@@ -59,6 +59,14 @@ type keyIndexEntry struct {
59
TombstoneUntil time.Time
60
}
61
62
+type upsertResult int
63
+
64
+const (
65
+ upsertRejected upsertResult = iota
66
+ upsertAccepted
67
+ upsertIgnored
68
+)
69
+
70
func NewRelaySet(bootstrapRelayURLs []string) *RelaySet {
71
set := &RelaySet{
72
relays: make(map[string]RelayState),
@@ -72,7 +80,8 @@ func NewRelaySet(bootstrapRelayURLs []string) *RelaySet {
80
// upsertDescriptorLocked applies a fully-merged RelayState to s.relays and
81
// updates the keyIndex. The caller MUST already hold s.mu as a write lock.
82
//
75
-// The returned bool indicates whether the descriptor was accepted. The
83
+// The returned status indicates whether the descriptor was accepted, ignored
84
+// as an already-superseded same-URL/same-identity announce, or rejected. The
85
// upsert is rejected when:
86
//
87
// 1. The signing identity has previously published a strictly newer
@@ -90,10 +99,10 @@ func NewRelaySet(bootstrapRelayURLs []string) *RelaySet {
99
// Equal IssuedAt values (idempotent re-broadcast) are accepted because the
100
// only mutation is the merged local telemetry on the existing URL slot,
101
// which never contradicts the cryptographic identity of the descriptor.
93
-func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allowCrossIdentityTakeover bool) bool {
102
+func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allowCrossIdentityTakeover bool) upsertResult {
103
relayURL := record.Descriptor.APIHTTPSAddr
104
if relayURL == "" {
96
- return false
105
+ return upsertRejected
106
}
107
address := strings.ToLower(strings.TrimSpace(record.Descriptor.Address))
108
if address != "" {
@@ -104,7 +113,14 @@ func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allo
113
if !prev.TombstoneUntil.IsZero() && now.After(prev.TombstoneUntil) {
114
delete(s.keyIndex, address)
115
} else if record.Descriptor.IssuedAt.Before(prev.IssuedAt) {
107
- return false
116
+ if existing, ok := s.relays[relayURL]; ok {
117
+ existingAddress := strings.ToLower(strings.TrimSpace(existing.Descriptor.Address))
118
+ if existingAddress == address && existing.Descriptor.ExpiresAt.After(now) &&
119
+ !existing.Descriptor.IssuedAt.Before(record.Descriptor.IssuedAt) {
120
+ return upsertIgnored
121
+ }
122
+ }
123
+ return upsertRejected
124
}
125
}
126
}
@@ -113,7 +129,7 @@ func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allo
129
existingAddress := strings.ToLower(strings.TrimSpace(existing.Descriptor.Address))
130
if existingAddress != "" && address != "" && existingAddress != address {
131
if !existing.Descriptor.ExpiresAt.IsZero() && existing.Descriptor.ExpiresAt.After(now) {
116
- return false
132
+ return upsertRejected
133
}
134
}
135
}
@@ -135,7 +151,7 @@ func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allo
151
TombstoneUntil: tombstoneUntil,
152
}
153
}
138
- return true
154
+ return upsertAccepted
155
}
156
157
func (s *RelaySet) SetRelayPolicy(policy RelayPolicy) {
@@ -403,11 +419,13 @@ func (s *RelaySet) ApplyRelayDiscoveryResponse(targetURL string, resp types.Disc
419
record.nextDirectRefreshAt = time.Time{}
420
}
421
406
- if !s.upsertDescriptorLocked(record, now, isAuthoritativeTarget) {
422
+ if upsert := s.upsertDescriptorLocked(record, now, isAuthoritativeTarget); upsert != upsertAccepted {
423
// The monotonic-IssuedAt check rejected this descriptor as a
408
- // rollback. The cryptographic identity in s.relays is unchanged,
409
- // but if we successfully reached the authoritative target we
410
- // should still credit it as alive on its existing URL slot.
424
+ // rollback, or ignored it because a newer same-identity descriptor
425
+ // for this URL is already present. The cryptographic identity in
426
+ // s.relays is unchanged, but if we successfully reached the
427
+ // authoritative target we should still credit it as alive on its
428
+ // existing URL slot.
429
if isAuthoritativeTarget && hasExistingAtURL {
430
if existingAtURL.consecutiveFailures != 0 || !existingAtURL.nextDirectRefreshAt.IsZero() {
431
existingAtURL.consecutiveFailures = 0
@@ -467,7 +485,8 @@ func (s *RelaySet) RecordDiscoveryRTT(relayURL string, rtt time.Duration, measur
485
// 5. After a successful upsert, the LRU cap is enforced; bootstrap and
486
// listener-confirmed entries are pinned.
487
//
470
-// Returns nil iff the descriptor was stored or idempotently refreshed.
488
+// Returns nil iff the descriptor was stored, idempotently refreshed, or is an
489
+// older same-URL/same-identity announce already superseded by local state.
490
func (s *RelaySet) InsertAnnounced(desc types.RelayDescriptor, now time.Time) error {
491
if now.IsZero() {
492
now = time.Now().UTC()
@@ -506,11 +525,15 @@ func (s *RelaySet) InsertAnnounced(desc types.RelayDescriptor, now time.Time) er
525
}
526
}
527
509
- if !s.upsertDescriptorLocked(record, now, false) {
528
+ switch s.upsertDescriptorLocked(record, now, false) {
529
+ case upsertAccepted:
530
+ s.enforceCapLocked()
531
+ return nil
532
+ case upsertIgnored:
533
+ return nil
534
+ case upsertRejected:
535
return errors.New("announced descriptor rejected by rollback or takeover guard")
536
}
512
-
513
- s.enforceCapLocked()
537
return nil
538
}
539