From 631686ab98a96c869061a8142981edc737e04dca Mon Sep 17 00:00:00 2001 From: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:48:54 +0000 Subject: [PATCH] fix: normalize configured_scope_at to canonical UTC before last-write-wins (#1865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UpdateNodeConfiguredScope stored the report timestamp raw and compared it lexicographically in the last-write-wins guard. Real firmware emits e.g. "2026-07-26T09:43:48.000000+00:00" (microseconds + numeric offset), and other observers may emit "Z" or a non-UTC offset like "+02:00". String-comparing those raw diverges from chronological order across observers/formats, so a stale report could overwrite a newer confirmed scope. normalizeReportTS parses the timestamp (RFC3339Nano/RFC3339) and stores it as canonical UTC RFC3339 ("...Z"), used for both storage and the comparison, so every stored configured_scope_at is either canonical or empty — never a mix. An unparseable/empty input yields "" and skips the ordering guard (writes), preserving prior behavior. New test TestUpdateNodeConfiguredScopeNormalizesAndOrdersByInstant proves a "+02:00" report that is lexicographically greater but chronologically earlier than the stored UTC value does not win, that a later "+02:00" report does, and that the firmware's microsecond/"+00:00" form canonicalizes to "Z". Co-Authored-By: Claude --- cmd/ingestor/db.go | 25 ++++++++++++++++- cmd/ingestor/issue1865_test.go | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index 4ba83e6b..572d2a9c 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -1625,6 +1625,25 @@ func (s *Store) UpdateNodeDefaultScope(pubkey, scope string) error { return err } +// normalizeReportTS parses an observer report timestamp and returns it in +// canonical UTC RFC3339 form ("2006-01-02T15:04:05Z"). It accepts both the +// firmware's fractional/offset form (e.g. "2026-07-26T09:43:48.000000+00:00") +// and plain "Z"/offset variants. An empty or unparseable input returns "" so +// the caller writes an empty configured_scope_at and skips the ordering guard; +// this keeps every stored timestamp either canonical or empty, never a mix of +// offset/precision formats that would break lexicographic last-write-wins. +func normalizeReportTS(raw string) string { + if raw == "" { + return "" + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + if t, err := time.Parse(layout, raw); err == nil { + return t.UTC().Format(time.RFC3339) + } + } + return "" +} + // UpdateNodeConfiguredScope records the region scopes a node has CONFIGURED, // as concrete evidence from an observer /neighbors report (#1865). Unlike // UpdateNodeDefaultScope (inferred, overwritten on every observation), this is @@ -1641,11 +1660,15 @@ func (s *Store) UpdateNodeConfiguredScope(pubkey, scope, reportedAt string) erro if pubkey == "" { return nil } + // Normalize to canonical UTC RFC3339 so the last-write-wins comparison is + // chronological, not lexicographic (see normalizeReportTS). Stored values + // are therefore always canonical or empty. + reportedAt = normalizeReportTS(reportedAt) // Last-write-wins: skip if the stored confirmation is newer-or-equal. if reportedAt != "" { var curAt sql.NullString row := s.db.QueryRow(`SELECT configured_scope_at FROM nodes WHERE public_key = ?`, pubkey) - if row.Scan(&curAt) == nil && curAt.Valid && curAt.String >= reportedAt { + if row.Scan(&curAt) == nil && curAt.Valid && curAt.String != "" && curAt.String >= reportedAt { return nil } } diff --git a/cmd/ingestor/issue1865_test.go b/cmd/ingestor/issue1865_test.go index 79a3c244..7857f33d 100644 --- a/cmd/ingestor/issue1865_test.go +++ b/cmd/ingestor/issue1865_test.go @@ -158,3 +158,52 @@ func TestUpdateNodeConfiguredScopeLastWriteWins(t *testing.T) { t.Errorf("inactive_nodes.configured_scope = %q, want 'de'", inactive.String) } } + +// TestUpdateNodeConfiguredScopeNormalizesAndOrdersByInstant proves the +// last-write-wins guard orders by chronological instant, not by raw string. +// A "+02:00" report that is lexicographically "greater" but chronologically +// EARLIER than the stored UTC value must not win, and stored timestamps are +// canonicalized to UTC "Z" form regardless of the incoming offset/precision. +func TestUpdateNodeConfiguredScopeNormalizesAndOrdersByInstant(t *testing.T) { + store := openNeighborsStore(t) + pk := "ee00000000000000000000000000000000000000000000000000000000000001" + seedNode(t, store, pk) + + // Baseline: noon UTC. + if err := store.UpdateNodeConfiguredScope(pk, "eu", "2026-07-25T12:00:00Z"); err != nil { + t.Fatal(err) + } + if _, at := configuredScope(t, store, pk); at.String != "2026-07-25T12:00:00Z" { + t.Fatalf("stored at = %q, want canonical 'Z' form", at.String) + } + + // "2026-07-25T13:30:00+02:00" == 11:30Z, chronologically EARLIER than 12:00Z, + // but lexicographically GREATER ("13:30..." > "12:00...Z"). Must be skipped. + if err := store.UpdateNodeConfiguredScope(pk, "stale", "2026-07-25T13:30:00+02:00"); err != nil { + t.Fatal(err) + } + if sc, _ := configuredScope(t, store, pk); sc.String != "eu" { + t.Errorf("configured_scope = %q, want 'eu' (earlier +02:00 report must not win)", sc.String) + } + + // "2026-07-25T15:00:00+02:00" == 13:00Z, chronologically LATER. Must update, + // and be stored canonicalized to UTC. + if err := store.UpdateNodeConfiguredScope(pk, "de", "2026-07-25T15:00:00+02:00"); err != nil { + t.Fatal(err) + } + sc, at := configuredScope(t, store, pk) + if sc.String != "de" { + t.Errorf("configured_scope = %q, want 'de' (later +02:00 report should win)", sc.String) + } + if at.String != "2026-07-25T13:00:00Z" { + t.Errorf("stored at = %q, want canonical '2026-07-25T13:00:00Z'", at.String) + } + + // Firmware's real format (microseconds + "+00:00") canonicalizes to "Z". + if err := store.UpdateNodeConfiguredScope(pk, "dk", "2026-07-26T09:43:48.000000+00:00"); err != nil { + t.Fatal(err) + } + if _, at := configuredScope(t, store, pk); at.String != "2026-07-26T09:43:48Z" { + t.Errorf("stored at = %q, want canonical '2026-07-26T09:43:48Z'", at.String) + } +}