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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user