fix(memory): 定宽时间戳存储 + rowid 确定性排序 + 遗留数据归一化
- formatTime 改用固定 30 字节布局(9 位小数秒),让 TEXT 列的字典序与时间序一致(RFC3339Nano 会去掉尾随零产生变宽字符串,秒内会反序) - memory_items / project / project_chapter 的 ORDER BY 全部补 rowid 兜底,消除同 tick 打平时的顺序不确定 - 开库时幂等归一化变宽的历史值;无法解析的行原样保留,不阻塞开库 - 回归测试:变宽排序反例、同一时间戳插入序、归一化幂等与跳过
This commit is contained in:
1 parent
1ac126fc9e
commit
31086ed1ab
10 files changed
+745
-61
No files matched your search
@@ -0,0 +1,135 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// timeLayout 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 storedTimeLen bytes wide. SQLite
|
||||
// compares these TEXT columns as plain strings, and equal width is what makes
|
||||
// that comparison agree with chronological order. UTC values render the zone as
|
||||
// a single "Z".
|
||||
const timeLayout = "2006-01-02T15:04:05.000000000Z07:00"
|
||||
|
||||
// storedTimeLen is the byte length of a UTC value formatted with timeLayout:
|
||||
// "2006-01-02T15:04:05.000000000Z" — the Z07:00 zone collapses to "Z" in UTC.
|
||||
const storedTimeLen = 30
|
||||
|
||||
// memoryItemsTable is the table whose timestamps are normalized when the store
|
||||
// opens. It must match the DDL in store.go.
|
||||
const memoryItemsTable = "memory_items"
|
||||
|
||||
// formatTime encodes t in UTC with the fixed-width timeLayout. Always writing
|
||||
// UTC keeps ordering and round-trips stable across processes, and the fixed
|
||||
// width is what lets ORDER BY created_at order these TEXT columns by time.
|
||||
func formatTime(t time.Time) string {
|
||||
return t.UTC().Format(timeLayout)
|
||||
}
|
||||
|
||||
// parseTime decodes a stored timestamp. time.RFC3339Nano accepts both the
|
||||
// fixed-width values this package writes and the legacy variable-width ones, so
|
||||
// the same parser serves reads and the open-time normalization. Blank input
|
||||
// yields a zero time.
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("memory: parse time %q: %w", s, err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// normalizeMemoryTimestamps rewrites every memory_items timestamp that is not
|
||||
// storedTimeLen bytes wide into the fixed-width timeLayout. Rows written before
|
||||
// the layout existed carry variable-width RFC3339Nano text whose string order
|
||||
// is not its time order; this brings them in line.
|
||||
//
|
||||
// It is idempotent: only rows whose column length differs from storedTimeLen are
|
||||
// selected, so running it again on a normalized table is a no-op. A row whose
|
||||
// value does not parse is left exactly as it is — one bad legacy value must not
|
||||
// keep the store from opening.
|
||||
func normalizeMemoryTimestamps(ctx context.Context, db *sql.DB) error {
|
||||
if err := normalizeTimeColumn(ctx, db, memoryItemsTable, "created_at"); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeColumn(ctx, db, memoryItemsTable, "updated_at")
|
||||
}
|
||||
|
||||
// normalizeTimeColumn rewrites every table.col value that is not storedTimeLen
|
||||
// bytes wide. table and col are compile-time constants, never user input, so
|
||||
// splicing them into the statement is safe; the length comparison and every
|
||||
// value written are parameterized. All rewrites are collected before any UPDATE
|
||||
// runs, so no write happens while the SELECT cursor is still open.
|
||||
func normalizeTimeColumn(ctx context.Context, db *sql.DB, table, col string) error {
|
||||
fixes, err := collectLegacyTimestamps(ctx, db, table, col)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range fixes {
|
||||
if _, err := db.ExecContext(ctx,
|
||||
"UPDATE "+f.table+" SET "+f.col+" = ? WHERE id = ?", f.value, f.id); err != nil {
|
||||
return fmt.Errorf("memory: normalize %s.%s: %w", f.table, f.col, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// timeFix is one pending timestamp rewrite: which table and column, which row
|
||||
// (by primary key id), and the fixed-width value to write.
|
||||
type timeFix struct {
|
||||
table string
|
||||
col string
|
||||
id string
|
||||
value string
|
||||
}
|
||||
|
||||
// collectLegacyTimestamps reads the rows of table.col whose value is not
|
||||
// storedTimeLen bytes wide and returns the parseable ones as pending rewrites.
|
||||
// Unparseable values are skipped, so normalization can never fail a row: the
|
||||
// only errors are genuine read failures.
|
||||
func collectLegacyTimestamps(ctx context.Context, db *sql.DB, table, col string) ([]timeFix, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
"SELECT id, "+col+" FROM "+table+" WHERE length("+col+") != ?", storedTimeLen)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: query legacy %s.%s: %w", table, col, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
fixes := make([]timeFix, 0)
|
||||
for rows.Next() {
|
||||
var id, raw string
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
return nil, fmt.Errorf("memory: scan legacy %s.%s: %w", table, col, err)
|
||||
}
|
||||
fixed, ok := reformatTime(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fixes = append(fixes, timeFix{table: table, col: col, id: id, value: fixed})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("memory: iterate legacy %s.%s: %w", table, col, err)
|
||||
}
|
||||
return fixes, nil
|
||||
}
|
||||
|
||||
// reformatTime re-encodes raw with timeLayout. The bool is false when raw is
|
||||
// already storedTimeLen wide or when it does not parse, in which case the caller
|
||||
// leaves the stored value untouched.
|
||||
func reformatTime(raw string) (string, bool) {
|
||||
if len(raw) == storedTimeLen {
|
||||
return "", false
|
||||
}
|
||||
t, err := parseTime(raw)
|
||||
if err != nil || t.IsZero() {
|
||||
return "", false
|
||||
}
|
||||
return formatTime(t), true
|
||||
}
|
||||
Reference in new issue
Block a user