fix(persist,channel): 同类定宽时间戳修复 — checkpoint 恢复与渠道会话列表排序
- persist: formatTime 定宽;loadLatestSQL 补 rowid 兜底;New 时归一化历史行(避免 LoadLatest 挑到更旧检查点) - channel/session: 写入定宽;listSessionSQL 补 rowid 兜底;核实无 INSERT 吃 DDL 默认值,故只加注释不改默认值 - 新增回归测试并做变异验证(改回 RFC3339Nano 即 FAIL)
This commit is contained in:
1 parent
31086ed1ab
commit
fbbfcc95ee
7 files changed
+445
-14
No files matched your search
@@ -19,6 +19,11 @@ import (
|
||||
// channel maps to exactly one internal session_id. created_at/updated_at follow
|
||||
// the project's timestamp convention; actor_role records the identity lane so the
|
||||
// mapping survives restarts.
|
||||
//
|
||||
// The created_at/updated_at DEFAULT expressions are a fallback only: every write
|
||||
// path (insertOrIgnoreSessionSQL) supplies explicit fixed-width UTC timestamps
|
||||
// via formatSessionTime, so the 3-digit-millisecond strftime defaults are never
|
||||
// exercised and no row mixes timestamp widths.
|
||||
const createTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS channel_sessions (
|
||||
platform TEXT NOT NULL,
|
||||
@@ -43,10 +48,13 @@ SELECT session_id FROM channel_sessions WHERE platform = ? AND channel_id = ?`
|
||||
|
||||
// listSessionsSQL selects every persisted channel mapping, newest first. Group
|
||||
// sessions key on a channel_id carrying groupChannelPrefix; direct sessions use
|
||||
// their channel id.
|
||||
// their channel id. created_at is fixed-width UTC text (see sessionTimeLayout),
|
||||
// so its text order is its time order; rowid breaks created_at ties so mappings
|
||||
// written in the same coarse clock tick still list in a stable order
|
||||
// (channel_sessions is an ordinary rowid table, not WITHOUT ROWID).
|
||||
const listSessionSQL = `
|
||||
SELECT platform, channel_id, session_id, actor_role FROM channel_sessions
|
||||
ORDER BY created_at DESC`
|
||||
ORDER BY created_at DESC, rowid DESC`
|
||||
|
||||
// groupChannelPrefix marks a channel id as a group conversation so a session
|
||||
// reconstructed from the backing table (where no IsGroup flag is persisted) can
|
||||
@@ -178,7 +186,7 @@ func (m *SessionManager) lookupCached(key string) (Session, bool) {
|
||||
// winning writer's session_id becomes the binding. It is called under the write
|
||||
// lock so the in-memory cache and the table stay in agreement.
|
||||
func (m *SessionManager) persist(ctx context.Context, platform, cid string, actor adapter.Actor) (string, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
now := formatSessionTime(time.Now())
|
||||
if _, err := m.db.ExecContext(ctx, insertOrIgnoreSessionSQL, platform, cid, newSessionID(), string(actor.Role), now, now); err != nil {
|
||||
return "", fmt.Errorf("session: persist %s/%s: %w", platform, cid, err)
|
||||
}
|
||||
|
||||
@@ -318,3 +318,98 @@ func TestNewRejectsNilDB(t *testing.T) {
|
||||
t.Fatalf("New(nil db) error = %v, want ErrNilDB", err)
|
||||
}
|
||||
}
|
||||
|
||||
// insertSessionAt writes a channel_sessions row directly with a caller-supplied
|
||||
// raw created_at/updated_at, standing in for a mapping written by past code.
|
||||
func insertSessionAt(t *testing.T, handle *sql.DB, cid, at string) {
|
||||
t.Helper()
|
||||
if _, err := handle.ExecContext(context.Background(),
|
||||
`INSERT INTO channel_sessions (platform, channel_id, session_id, actor_role, created_at, updated_at)
|
||||
VALUES ('qq', ?, ?, 'owner', ?, ?)`,
|
||||
cid, "sess-"+cid, at, at); err != nil {
|
||||
t.Fatalf("insert %s: %v", cid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListNewestFirstDeterministic covers the ordering contract: three mappings
|
||||
// written within the same coarse clock tick share one created_at, so only the
|
||||
// rowid tiebreak can define their order. List is newest first, so the row
|
||||
// inserted last (highest rowid) must come first. The cache is deliberately empty
|
||||
// so List reads the persisted order instead of cached entries.
|
||||
func TestListNewestFirstDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, handle := newTestManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const at = "2026-09-07T03:20:05.830000000Z"
|
||||
for _, cid := range []string{"direct-a", "direct-b", "direct-c"} {
|
||||
insertSessionAt(t, handle, cid, at)
|
||||
}
|
||||
|
||||
got, err := m.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List = %v", err)
|
||||
}
|
||||
want := []string{"direct-c", "direct-b", "direct-a"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("List = %d sessions, want %d", len(got), len(want))
|
||||
}
|
||||
for i, w := range want {
|
||||
if got[i].ChannelID != w {
|
||||
t.Errorf("got[%d].ChannelID = %q, want %q (rowid DESC tiebreak)", i, got[i].ChannelID, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListNewestFirstByCreatedAt checks the created_at DESC half: the mapping
|
||||
// with the latest fixed-width timestamp is listed first even though it was
|
||||
// inserted first and therefore has the lowest rowid.
|
||||
func TestListNewestFirstByCreatedAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, handle := newTestManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
insertSessionAt(t, handle, "direct-newest", "2026-09-07T03:20:05.830978900Z")
|
||||
insertSessionAt(t, handle, "direct-oldest", "2026-09-07T03:20:05.830000000Z")
|
||||
|
||||
got, err := m.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List = %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("List = %d sessions, want 2", len(got))
|
||||
}
|
||||
if got[0].ChannelID != "direct-newest" || got[1].ChannelID != "direct-oldest" {
|
||||
t.Errorf("List order = [%q %q], want newest first", got[0].ChannelID, got[1].ChannelID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistWritesFixedWidthTimestamps pins the write path: GetOrCreate must
|
||||
// store created_at/updated_at with the fixed-width layout so channel_sessions
|
||||
// text ordering matches time ordering. RFC3339Nano would drop trailing zeros
|
||||
// and persist a variable-width value instead.
|
||||
func TestPersistWritesFixedWidthTimestamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, handle := newTestManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := m.GetOrCreate(ctx, directMsg("o-100"), testOwner()); err != nil {
|
||||
t.Fatalf("GetOrCreate = %v", err)
|
||||
}
|
||||
var created, updated string
|
||||
if err := handle.QueryRowContext(ctx,
|
||||
`SELECT created_at, updated_at FROM channel_sessions WHERE channel_id = ?`,
|
||||
"direct-o-100").Scan(&created, &updated); err != nil {
|
||||
t.Fatalf("read stored timestamps: %v", err)
|
||||
}
|
||||
if len(created) != sessionTimeLen || len(updated) != sessionTimeLen {
|
||||
t.Errorf("stored timestamps = %q/%q (%d/%d bytes), want fixed width %d",
|
||||
created, updated, len(created), len(updated), sessionTimeLen)
|
||||
}
|
||||
if created != updated {
|
||||
t.Errorf("created_at %q != updated_at %q, want the same value on insert", created, updated)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package session
|
||||
|
||||
import "time"
|
||||
|
||||
// sessionTimeLayout is the fixed-width timestamp layout used for every stored
|
||||
// created_at/updated_at value. Unlike time.RFC3339Nano it never drops trailing
|
||||
// fractional zeros, so every value is exactly sessionTimeLen bytes wide. SQLite
|
||||
// compares these TEXT columns as plain strings, and equal width is what makes
|
||||
// that comparison agree with chronological order. Values are written in UTC, so
|
||||
// the Z07:00 zone renders as a single "Z".
|
||||
const sessionTimeLayout = "2006-01-02T15:04:05.000000000Z07:00"
|
||||
|
||||
// sessionTimeLen is the byte length of a UTC value formatted with
|
||||
// sessionTimeLayout: "2006-01-02T15:04:05.000000000Z" — the Z07:00 zone
|
||||
// collapses to "Z" in UTC.
|
||||
const sessionTimeLen = 30
|
||||
|
||||
// formatSessionTime encodes t in UTC with the fixed-width sessionTimeLayout.
|
||||
func formatSessionTime(t time.Time) string {
|
||||
return t.UTC().Format(sessionTimeLayout)
|
||||
}
|
||||
Reference in new issue
Block a user