Skip to content

Commit 0c8cc8c

Browse files
committed
fix(postgres): normalize message timestamps for push
1 parent 1fbb2a7 commit 0c8cc8c

4 files changed

Lines changed: 169 additions & 2 deletions

File tree

internal/db/messages.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,6 +1220,20 @@ func (db *DB) MessageContentHashFingerprint(sessionID string) (string, error) {
12201220
// (selectMessageCols coalesces the same way); without it a single
12211221
// imported NULL row would error here and abort the whole parse-diff run.
12221222
func (db *DB) MessageRoleTimeFingerprint(sessionID string) (string, error) {
1223+
return db.MessageRoleTimeFingerprintWithTimestampNormalizer(
1224+
sessionID, nil,
1225+
)
1226+
}
1227+
1228+
// MessageRoleTimeFingerprintWithTimestampNormalizer returns the same
1229+
// fingerprint as MessageRoleTimeFingerprint after applying normalizeTimestamp
1230+
// to each timestamp value. It lets callers compare against stores that preserve
1231+
// a different timestamp representation while keeping the query and field
1232+
// ordering identical to the raw parse-diff fingerprint.
1233+
func (db *DB) MessageRoleTimeFingerprintWithTimestampNormalizer(
1234+
sessionID string,
1235+
normalizeTimestamp func(string) string,
1236+
) (string, error) {
12231237
rows, err := db.getReader().Query(
12241238
`SELECT ordinal, role, COALESCE(timestamp, '')
12251239
FROM messages
@@ -1240,6 +1254,9 @@ func (db *DB) MessageRoleTimeFingerprint(sessionID string) (string, error) {
12401254
return "", err
12411255
}
12421256
role = SanitizeUTF8(role)
1257+
if normalizeTimestamp != nil {
1258+
timestamp = normalizeTimestamp(timestamp)
1259+
}
12431260
fmt.Fprintf(&b, "%d|%d:%s|%d:%s;",
12441261
ordinal, len(role), role, len(timestamp), timestamp)
12451262
}

internal/postgres/push.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,8 +1135,8 @@ func (s *Sync) pushMessages(
11351135
err,
11361136
)
11371137
}
1138-
localRoleTimeFP, err := s.local.MessageRoleTimeFingerprint(
1139-
sessionID,
1138+
localRoleTimeFP, err := localMessageRoleTimePGFingerprint(
1139+
s.local, sessionID,
11401140
)
11411141
if err != nil {
11421142
return 0, fmt.Errorf(
@@ -1601,6 +1601,23 @@ func pgMessageContentHashFingerprint(
16011601
return b.String(), rows.Err()
16021602
}
16031603

1604+
func localMessageRoleTimePGFingerprint(
1605+
local *db.DB, sessionID string,
1606+
) (string, error) {
1607+
return local.MessageRoleTimeFingerprintWithTimestampNormalizer(
1608+
sessionID,
1609+
pgPushTimestampFingerprintText,
1610+
)
1611+
}
1612+
1613+
func pgPushTimestampFingerprintText(value string) string {
1614+
t, ok := ParseSQLiteTimestamp(value)
1615+
if !ok {
1616+
return ""
1617+
}
1618+
return FormatISO8601(t.Truncate(time.Microsecond))
1619+
}
1620+
16041621
func pgMessageRoleTimeFingerprint(
16051622
ctx context.Context, tx *sql.Tx, sessionID string,
16061623
) (string, error) {

internal/postgres/push_pgtest_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,68 @@ func TestPushMessageFlagsRewriteRegression(t *testing.T) {
262262
"private chain of thought")
263263
}
264264

265+
func TestPushMessageNanosecondTimestampNoRewriteRegression(t *testing.T) {
266+
pgURL := testPGURL(t)
267+
268+
const schema = "agentsview_push_msgtime_nanos_test"
269+
pg, err := Open(pgURL, schema, true)
270+
require.NoError(t, err, "Open")
271+
defer pg.Close()
272+
273+
ctx := context.Background()
274+
_, err = pg.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`)
275+
require.NoError(t, err, "drop schema")
276+
require.NoError(t, EnsureSchema(ctx, pg, schema), "EnsureSchema")
277+
278+
localDB, err := db.Open(filepath.Join(t.TempDir(), "local.db"))
279+
require.NoError(t, err, "db.Open")
280+
defer localDB.Close()
281+
282+
sync := &Sync{
283+
pg: pg,
284+
local: localDB,
285+
machine: "test-machine",
286+
schema: schema,
287+
schemaDone: true,
288+
}
289+
290+
const sessID = "message-nanotime-rewrite-001"
291+
sess := db.Session{
292+
ID: sessID,
293+
Project: "test-proj",
294+
Machine: "test-machine",
295+
Agent: "shelley",
296+
MessageCount: 1,
297+
UserMessageCount: 1,
298+
CreatedAt: "2026-01-01T00:00:00Z",
299+
}
300+
require.NoError(t, localDB.UpsertSession(sess), "UpsertSession")
301+
require.NoError(t, localDB.InsertMessages([]db.Message{{
302+
SessionID: sessID,
303+
Ordinal: 1,
304+
Role: "user",
305+
Content: "question",
306+
ContentLength: len("question"),
307+
Timestamp: "2026-01-01T00:00:00.123456789Z",
308+
}}), "InsertMessages")
309+
310+
_, err = sync.Push(ctx, false, nil)
311+
require.NoError(t, err, "Push first timestamp")
312+
assertPGMessageTimestamp(t, pg, sessID, 1,
313+
"2026-01-01T00:00:00.123456Z")
314+
315+
ctidBefore := pgMessageCTID(t, pg, sessID, 1)
316+
require.NoError(t, localDB.SetSyncState("last_push_at", ""),
317+
"clearing last_push_at")
318+
require.NoError(t, localDB.SetSyncState(lastPushBoundaryStateKey, ""),
319+
"clearing boundary state")
320+
321+
_, err = sync.Push(ctx, false, nil)
322+
require.NoError(t, err, "Push same timestamp")
323+
assert.Equal(t, ctidBefore, pgMessageCTID(t, pg, sessID, 1),
324+
"microsecond-equivalent timestamps should hit the fast path")
325+
}
326+
265327
// TestPushSessionTerminationStatus verifies that pushSession round-trips
266328
// the termination_status column to PG: a non-nil value writes the string,
267329
// and a subsequent push with nil clears the column back to NULL via the
@@ -547,6 +609,40 @@ func assertPGMessageThinking(
547609
assert.Equal(t, wantThinkingText, gotThinkingText)
548610
}
549611

612+
func assertPGMessageTimestamp(
613+
t *testing.T,
614+
pg *sql.DB,
615+
sessionID string,
616+
ordinal int,
617+
want string,
618+
) {
619+
t.Helper()
620+
var gotTime sql.NullTime
621+
require.NoError(t, pg.QueryRow(
622+
`SELECT timestamp FROM messages
623+
WHERE session_id = $1 AND ordinal = $2`,
624+
sessionID, ordinal,
625+
).Scan(&gotTime), "read pg message timestamp")
626+
require.True(t, gotTime.Valid, "timestamp should be non-NULL")
627+
assert.Equal(t, want, FormatISO8601(gotTime.Time))
628+
}
629+
630+
func pgMessageCTID(
631+
t *testing.T,
632+
pg *sql.DB,
633+
sessionID string,
634+
ordinal int,
635+
) string {
636+
t.Helper()
637+
var ctid string
638+
require.NoError(t, pg.QueryRow(
639+
`SELECT ctid::text FROM messages
640+
WHERE session_id = $1 AND ordinal = $2`,
641+
sessionID, ordinal,
642+
).Scan(&ctid), "read pg message ctid")
643+
return ctid
644+
}
645+
550646
// TestPushMessagesSanitizesNULBytes verifies that a message whose
551647
// model and source fields carry NUL bytes (observed in production:
552648
// the Antigravity gen_metadata heuristic persisted a raw protobuf

internal/postgres/push_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package postgres
22

33
import (
44
"encoding/json"
5+
"path/filepath"
56
"testing"
67

78
"github.com/stretchr/testify/assert"
@@ -307,6 +308,42 @@ func TestSessionPushFingerprintNoFieldCollisions(
307308
"length-prefixed fingerprints should not collide")
308309
}
309310

311+
func TestLocalMessageRoleTimePGFingerprintNormalizesNanoseconds(
312+
t *testing.T,
313+
) {
314+
localDB, err := db.Open(filepath.Join(t.TempDir(), "local.db"))
315+
require.NoError(t, err, "db.Open")
316+
defer localDB.Close()
317+
318+
const sessID = "pg-role-time-nanos"
319+
require.NoError(t, localDB.UpsertSession(db.Session{
320+
ID: sessID,
321+
Project: "proj",
322+
Machine: "host",
323+
Agent: "shelley",
324+
CreatedAt: "2026-03-11T12:34:56Z",
325+
}), "UpsertSession")
326+
require.NoError(t, localDB.InsertMessages([]db.Message{{
327+
SessionID: sessID,
328+
Ordinal: 1,
329+
Role: "assistant",
330+
Content: "answer",
331+
ContentLength: len("answer"),
332+
Timestamp: "2026-03-11T12:34:56.123456789Z",
333+
}}), "InsertMessages")
334+
335+
got, err := localMessageRoleTimePGFingerprint(localDB, sessID)
336+
require.NoError(t, err)
337+
assert.Equal(t,
338+
"1|9:assistant|27:2026-03-11T12:34:56.123456Z;",
339+
got)
340+
341+
raw, err := localDB.MessageRoleTimeFingerprint(sessID)
342+
require.NoError(t, err)
343+
assert.NotEqual(t, raw, got,
344+
"PG push fingerprint must not use raw nanosecond text")
345+
}
346+
310347
func TestFinalizePushStatePersistsEmptyBoundary(
311348
t *testing.T,
312349
) {

0 commit comments

Comments
 (0)