fix(memory): 定宽时间戳存储 + rowid 确定性排序 + 遗留数据归一化

- formatTime 改用固定 30 字节布局(9 位小数秒),让 TEXT 列的字典序与时间序一致(RFC3339Nano 会去掉尾随零产生变宽字符串,秒内会反序)
- memory_items / project / project_chapter 的 ORDER BY 全部补 rowid 兜底,消除同 tick 打平时的顺序不确定
- 开库时幂等归一化变宽的历史值;无法解析的行原样保留,不阻塞开库
- 回归测试:变宽排序反例、同一时间戳插入序、归一化幂等与跳过
This commit is contained in:
AskaEth committed 2026-10-03 16:08:50 +08:00
1 parent 1ac126fc9e
commit 31086ed1ab
10 files changed
+745 -61

No files matched your search

+21 -31
View File
@@ -158,7 +158,8 @@ type rowScanner interface {
// db must be a live, migrated connection (the migration framework itself is
// owned by internal/db). A nil db returns ErrNilDB. log may be nil, in which
// case a discard logger is used. ctx controls schema creation and must be
// honored by the caller.
// honored by the caller. Any legacy variable-width timestamps are normalized in
// place (see normalizeMemoryTimestamps), so text ordering matches time ordering.
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil {
return nil, ErrNilDB
@@ -176,6 +177,9 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
if err := ensureProjectIDColumn(ctx, db); err != nil {
return nil, err
}
if err := normalizeMemoryTimestamps(ctx, db); err != nil {
return nil, err
}
return &Store{db: db, log: log}, nil
}
@@ -227,11 +231,12 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
return item, nil
}
// ListByPersona returns every item for personaID, newest first. It is the
// persona-isolation boundary: items belonging to other personas are never
// returned, so personas never share memory (design §4). Use ListScope when a
// caller needs to see shared user/project scopes or another persona's read-only
// items.
// ListByPersona returns every item for personaID, newest first. rowid breaks
// created_at ties so items written within the same clock tick keep a stable
// newest-first order. It is the persona-isolation boundary: items belonging to
// other personas are never returned, so personas never share memory (design
// §4). Use ListScope when a caller needs to see shared user/project scopes or
// another persona's read-only items.
func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) {
if strings.TrimSpace(personaID) == "" {
return nil, ErrMissingPersona
@@ -239,22 +244,24 @@ func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryIt
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE persona_id = ?
ORDER BY created_at DESC`, personaID)
ORDER BY created_at DESC, rowid DESC`, personaID)
if err != nil {
return nil, fmt.Errorf("memory: list persona items: %w", err)
}
return scanItems(rows, "persona item")
}
// List returns every memory item, newest first, across all personas. It is the
// aggregate/admin view that enumerates all persisted memory regardless of the
// persona-isolation boundary; callers that must respect isolation should use
// ListByPersona or ListScope instead. The returned slice is non-nil.
// List returns every memory item, newest first, across all personas. rowid
// breaks created_at ties so items written within the same clock tick keep a
// stable newest-first order. It is the aggregate/admin view that enumerates all
// persisted memory regardless of the persona-isolation boundary; callers that
// must respect isolation should use ListByPersona or ListScope instead. The
// returned slice is non-nil.
func (s *Store) List(ctx context.Context) ([]MemoryItem, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items
ORDER BY created_at DESC`)
ORDER BY created_at DESC, rowid DESC`)
if err != nil {
return nil, fmt.Errorf("memory: list items: %w", err)
}
@@ -378,7 +385,8 @@ func applyTimestamps(item MemoryItem) (MemoryItem, error) {
}
// scanItem reads one memory_items row. tags is stored as a JSON array string;
// timestamps are stored as RFC3339 strings.
// timestamps are stored as fixed-width UTC strings (timeLayout) so text
// comparison orders them chronologically.
func scanItem(sc rowScanner) (MemoryItem, error) {
var (
it MemoryItem
@@ -421,24 +429,6 @@ func newID() (string, error) {
return hex.EncodeToString(b), nil
}
// formatTime encodes t in UTC as an RFC3339 string. The value is always written
// as UTC so ordering and round-trips are stable across processes.
func formatTime(t time.Time) string {
return t.UTC().Format(time.RFC3339Nano)
}
// parseTime decodes a stored RFC3339 string. 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
}
// discardLogger returns log if non-nil, otherwise a logger that writes nowhere.
func discardLogger(log *slog.Logger) *slog.Logger {
if log != nil {