diff --git a/cmd/genesis-writer/entities_comment.go b/cmd/genesis-writer/entities_comment.go index 55c6575e..71fc6005 100644 --- a/cmd/genesis-writer/entities_comment.go +++ b/cmd/genesis-writer/entities_comment.go @@ -125,7 +125,7 @@ func (w *Writer) writeCommentPass( EntityID: c.EntityID, EntityType: c.EntityType, TrackTimestampS: c.TrackTimestampS, - CreatedAt: c.CreatedAt.Format(time.RFC3339), + CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339), } // Attach parent comment if this is a reply. @@ -177,7 +177,7 @@ func (w *Writer) writeCommentReactions(ctx context.Context) error { return cr, err }, func(ctx context.Context, cr commentReaction) error { - metaJSON, err := json.Marshal(createdAtMeta{CreatedAt: cr.createdAt.Format(time.RFC3339)}) + metaJSON, err := json.Marshal(createdAtMeta{CreatedAt: cr.createdAt.UTC().Format(time.RFC3339)}) if err != nil { return fmt.Errorf("marshal comment reaction metadata: %w", err) } diff --git a/cmd/genesis-writer/entities_comment_pin.go b/cmd/genesis-writer/entities_comment_pin.go index 9fdd1144..2b3ec8a2 100644 --- a/cmd/genesis-writer/entities_comment_pin.go +++ b/cmd/genesis-writer/entities_comment_pin.go @@ -74,7 +74,7 @@ func (w *Writer) writeCommentPins(ctx context.Context) error { func(ctx context.Context, p sourceCommentPin) error { metaJSON, err := json.Marshal(commentPinMetadata{ EntityID: p.TrackID, - CreatedAt: p.PinnedAt.Format(time.RFC3339), + CreatedAt: p.PinnedAt.UTC().Format(time.RFC3339), }) if err != nil { return fmt.Errorf("marshal comment pin metadata for track %d: %w", p.TrackID, err) diff --git a/cmd/genesis-writer/entities_dashboard_wallet.go b/cmd/genesis-writer/entities_dashboard_wallet.go index 8b944a74..b02b5a91 100644 --- a/cmd/genesis-writer/entities_dashboard_wallet.go +++ b/cmd/genesis-writer/entities_dashboard_wallet.go @@ -39,7 +39,7 @@ func (w *Writer) writeDashboardWalletUsers(ctx context.Context) error { func(ctx context.Context, d sourceDashboardWalletUser) error { metaJSON, err := json.Marshal(dashboardWalletMetadata{ Wallet: d.Wallet, - CreatedAt: d.CreatedAt.Format(time.RFC3339), + CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339), IsDelete: d.IsDelete, }) if err != nil { diff --git a/cmd/genesis-writer/entities_developer_app.go b/cmd/genesis-writer/entities_developer_app.go index e878d858..c70687a3 100644 --- a/cmd/genesis-writer/entities_developer_app.go +++ b/cmd/genesis-writer/entities_developer_app.go @@ -59,7 +59,7 @@ func (w *Writer) writeDeveloperApps(ctx context.Context) error { Description: deref(d.Description), ImageURL: deref(d.ImageURL), IsPersonalAccess: d.IsPersonalAccess, - CreatedAt: d.CreatedAt.Format(time.RFC3339), + CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339), } metaJSON, err := json.Marshal(meta) if err != nil { @@ -112,7 +112,7 @@ func (w *Writer) writeGrants(ctx context.Context) error { metaJSON, err := json.Marshal(grantMetadata{ IsRevoked: g.IsRevoked, GranteeAddress: g.GranteeAddress, - CreatedAt: g.CreatedAt.Format(time.RFC3339), + CreatedAt: g.CreatedAt.UTC().Format(time.RFC3339), }) if err != nil { return fmt.Errorf("marshal grant metadata: %w", err) diff --git a/cmd/genesis-writer/entities_event.go b/cmd/genesis-writer/entities_event.go index d5ce7009..c6b139f2 100644 --- a/cmd/genesis-writer/entities_event.go +++ b/cmd/genesis-writer/entities_event.go @@ -50,13 +50,13 @@ func (w *Writer) writeEvents(ctx context.Context) error { meta := eventMetadata{ EventType: e.EventType, EntityID: e.EntityID, - CreatedAt: e.CreatedAt.Format(time.RFC3339), + CreatedAt: e.CreatedAt.UTC().Format(time.RFC3339), } if e.EntityType != nil { meta.EntityType = *e.EntityType } if e.EndDate != nil { - meta.EndDate = e.EndDate.Format(time.RFC3339) + meta.EndDate = e.EndDate.UTC().Format(time.RFC3339) } meta.EventData = unmarshalJSONB(e.EventData) diff --git a/cmd/genesis-writer/entities_playlist.go b/cmd/genesis-writer/entities_playlist.go index 57f0c694..b8a7b066 100644 --- a/cmd/genesis-writer/entities_playlist.go +++ b/cmd/genesis-writer/entities_playlist.go @@ -176,7 +176,7 @@ func (w *Writer) writePlaylists(ctx context.Context) error { }, func(ctx context.Context, p sourcePlaylist) error { inner := playlistMetadataInner{ - CreatedAt: p.CreatedAt.Format(time.RFC3339), + CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339), PlaylistName: deref(p.PlaylistName), Description: deref(p.Description), IsAlbum: p.IsAlbum, diff --git a/cmd/genesis-writer/entities_social.go b/cmd/genesis-writer/entities_social.go index 47c9b43e..624267f3 100644 --- a/cmd/genesis-writer/entities_social.go +++ b/cmd/genesis-writer/entities_social.go @@ -26,7 +26,7 @@ type socialMeta struct { } func fmtCreatedAt(t time.Time) string { - return t.Format(time.RFC3339) + return t.UTC().Format(time.RFC3339) } // --- Follows --- diff --git a/cmd/genesis-writer/entities_track.go b/cmd/genesis-writer/entities_track.go index cd953eb9..2ab90525 100644 --- a/cmd/genesis-writer/entities_track.go +++ b/cmd/genesis-writer/entities_track.go @@ -143,7 +143,7 @@ type sourceTrack struct { // a column the writer forgets to carry is invisible in the output otherwise. func buildTrackMetadata(t sourceTrack, collaborators []int64) trackMetadataInner { inner := trackMetadataInner{ - CreatedAt: t.CreatedAt.Format(time.RFC3339), + CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339), OwnerID: t.OwnerID, Title: deref(t.Title), Description: deref(t.Description), @@ -352,7 +352,7 @@ func (w *Writer) writeTrackDownloads(ctx context.Context) error { City: deref(d.City), Region: deref(d.Region), Country: deref(d.Country), - CreatedAt: d.CreatedAt.Format(time.RFC3339), + CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339), } metaJSON, err := json.Marshal(meta) if err != nil { diff --git a/cmd/genesis-writer/entities_user.go b/cmd/genesis-writer/entities_user.go index fd05d5c0..08d6674a 100644 --- a/cmd/genesis-writer/entities_user.go +++ b/cmd/genesis-writer/entities_user.go @@ -107,7 +107,7 @@ func (w *Writer) writeUsers(ctx context.Context) error { }, func(ctx context.Context, u sourceUser) error { meta := userMetadata{ - CreatedAt: u.CreatedAt.Format(time.RFC3339), + CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339), Name: deref(u.Name), Handle: deref(u.Handle), Bio: deref(u.Bio), diff --git a/cmd/genesis-writer/timestamps_test.go b/cmd/genesis-writer/timestamps_test.go new file mode 100644 index 00000000..c627defb --- /dev/null +++ b/cmd/genesis-writer/timestamps_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +// Every timestamp the writer emits must be rendered in UTC. +// +// The source schema mixes column types: most timestamps are `timestamp +// without time zone`, which pgx decodes with a UTC location, but seven columns +// the writer reads are `timestamp with time zone` -- playlist_tracks +// created_at/updated_at, email_access and encrypted_emails created_at/ +// updated_at, and users.last_active_at. pgx decodes those into time.Local. +// +// Formatting a Local-zone time yields an offset-bearing string such as +// 2024-02-26T18:00:34-08:00 instead of 2024-02-27T02:00:34Z. Both name the +// same instant, and the indexer normalizes on read, so this is not currently +// data corruption -- but it makes the emitted bytes depend on the machine's +// TZ. The same source row would produce different metadata, and therefore a +// different transaction hash, on a UTC host than on a developer's laptop. A +// genesis artifact has to be reproducible. +// +// This is a source-level check on purpose. Most metadata is built inside +// per-entity closures that cannot be called in isolation, so a behavioural +// test would cover whichever one path it happened to reach. The failure mode +// worth guarding is a *new* emission site added later without .UTC(), and only +// scanning the package catches that. +func TestAllEmittedTimestampsAreUTC(t *testing.T) { + // A .Format(time.RFC3339) whose receiver does not end in .UTC(). + unnormalized := regexp.MustCompile(`(\.UTC\(\))?\.Format\(time\.RFC3339\)`) + + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read package dir: %v", err) + } + + var offenders []string + checked := 0 + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + src, err := os.ReadFile(filepath.Clean(name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + for i, line := range strings.Split(string(src), "\n") { + for _, m := range unnormalized.FindAllStringSubmatch(line, -1) { + checked++ + if m[1] == "" { // no .UTC() before .Format + offenders = append(offenders, + filepath.Join(name)+":"+itoa(i+1)+" "+strings.TrimSpace(line)) + } + } + } + } + + if checked == 0 { + t.Fatal("found no RFC3339 formatting sites -- this guard has stopped guarding anything") + } + if len(offenders) > 0 { + t.Errorf("%d timestamp(s) emitted without .UTC(); on a non-UTC host these render "+ + "with a local offset and the artifact stops being reproducible:\n %s", + len(offenders), strings.Join(offenders, "\n ")) + } +} + +// The premise above: a Local-zone time formats with an offset, and .UTC() +// removes it while naming the same instant. If the standard library ever +// stopped behaving this way the guard would be pointless. +func TestUTCNormalizationRemovesLocalOffset(t *testing.T) { + zone := time.FixedZone("PST", -8*60*60) + local := time.Date(2024, 2, 26, 18, 0, 34, 0, zone) + + if got := local.Format(time.RFC3339); got != "2024-02-26T18:00:34-08:00" { + t.Fatalf("unnormalized = %s, want an offset-bearing timestamp", got) + } + got := local.UTC().Format(time.RFC3339) + if got != "2024-02-27T02:00:34Z" { + t.Errorf("normalized = %s, want 2024-02-27T02:00:34Z", got) + } + if !strings.HasSuffix(got, "Z") { + t.Errorf("normalized timestamp %s is not zone-independent", got) + } +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + var b []byte + for v > 0 { + b = append([]byte{byte('0' + v%10)}, b...) + v /= 10 + } + return string(b) +}