| 1 | package sessioncatalog |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | ) |
| 8 | |
| 9 | func (c *Catalog) UpsertSession(ctx context.Context, record SessionRecord) error { |
| 10 | record = normalizeSessionRecord(record) |
| 11 | record.enqueueSequence = c.mutationSeq.Add(1) |
| 12 | return c.upsertSessions(ctx, []SessionRecord{record}, nil, "write") |
| 13 | } |
| 14 | |
| 15 | func (c *Catalog) upsertSessions(ctx context.Context, records []SessionRecord, generations map[string]int64, reason string) error { |
| 16 | _, err := c.upsertSessionsWithNotification(ctx, records, generations, reason, true, upsertExactSource) |
| 17 | return err |
| 18 | } |
| 19 | |
| 20 | func (c *Catalog) upsertExactPathSession(ctx context.Context, record SessionRecord) (bool, error) { |
| 21 | dirty, err := c.upsertSessionsWithNotification(ctx, []SessionRecord{record}, nil, "write", true, upsertExactSource) |
| 22 | return len(dirty) > 0, err |
| 23 | } |
| 24 | |
| 25 | func (c *Catalog) upsertSessionsWithNotification(ctx context.Context, records []SessionRecord, generations map[string]int64, reason string, notify bool, mode sessionUpsertMode) (map[string]DirectoryTarget, error) { |
| 26 | dirtyDirectories := map[string]DirectoryTarget{} |
| 27 | if len(records) == 0 { |
| 28 | return dirtyDirectories, nil |
| 29 | } |
| 30 | c.mutationMu.Lock() |
| 31 | defer c.mutationMu.Unlock() |
| 32 | filtered := records[:0] |
| 33 | for _, record := range records { |
| 34 | pathKey := c.pathKey(record.Path) |
| 35 | if c.pathMutationAllowed(pathKey, record.enqueueSequence) { |
| 36 | filtered = append(filtered, record) |
| 37 | } |
| 38 | } |
| 39 | records = filtered |
| 40 | if len(records) == 0 { |
| 41 | return dirtyDirectories, nil |
| 42 | } |
| 43 | if mode == upsertExactSource { |
| 44 | prepared := make([]SessionRecord, 0, len(records)) |
| 45 | for _, raw := range records { |
| 46 | record, skip, projectionDirty, err := c.prepareExactPathProjection(ctx, raw) |
| 47 | if err != nil { |
| 48 | return dirtyDirectories, err |
| 49 | } |
| 50 | if projectionDirty { |
| 51 | dirtyDirectories[c.pathKey(record.Directory)] = DirectoryTarget{ |
| 52 | Path: record.Directory, Scope: record.Scope, WorkspaceRoot: record.WorkspaceRoot, |
| 53 | } |
| 54 | } |
| 55 | if !skip { |
| 56 | prepared = append(prepared, record) |
| 57 | } |
| 58 | } |
| 59 | records = prepared |
| 60 | if len(records) == 0 { |
| 61 | return dirtyDirectories, nil |
| 62 | } |
| 63 | } |
| 64 | tx, err := c.db.BeginTx(ctx, nil) |
| 65 | if err != nil { |
| 66 | return dirtyDirectories, err |
| 67 | } |
| 68 | affected := map[TopicKey]struct{}{} |
| 69 | roots := map[string]struct{}{} |
| 70 | directoryGenerations := map[string]int64{} |
| 71 | for _, raw := range records { |
| 72 | record := normalizeSessionRecord(raw) |
| 73 | pathKey := c.pathKey(record.Path) |
| 74 | directoryKey := c.pathKey(record.Directory) |
| 75 | remapped, err := removeRemappedSessionIdentity(ctx, tx, record.Path, pathKey) |
| 76 | if err != nil { |
| 77 | _ = tx.Rollback() |
| 78 | return dirtyDirectories, err |
| 79 | } |
| 80 | for _, key := range remapped { |
| 81 | affected[key] = struct{}{} |
| 82 | } |
| 83 | var previous TopicKey |
| 84 | if err := tx.QueryRowContext(ctx, `SELECT scope,workspace_root,workspace_root_key,topic_id FROM catalog_sessions WHERE path_key=?`, pathKey). |
| 85 | Scan(&previous.Scope, &previous.WorkspaceRoot, &previous.workspaceKey, &previous.TopicID); err == nil && previous.TopicID != "" { |
| 86 | affected[previous] = struct{}{} |
| 87 | } else if err != nil && !errors.Is(err, sql.ErrNoRows) { |
| 88 | _ = tx.Rollback() |
| 89 | return dirtyDirectories, err |
| 90 | } |
| 91 | generation := int64(0) |
| 92 | if generations != nil { |
| 93 | generation = generations[record.Path] |
| 94 | } else if cached, ok := directoryGenerations[directoryKey]; ok { |
| 95 | generation = cached |
| 96 | } else { |
| 97 | _ = tx.QueryRowContext(ctx, `SELECT scan_generation FROM catalog_directories WHERE path_key=?`, directoryKey).Scan(&generation) |
| 98 | directoryGenerations[directoryKey] = generation |
| 99 | } |
| 100 | if err := c.upsertSessionRow(ctx, tx, record, pathKey, directoryKey, generation, mode); err != nil { |
| 101 | _ = tx.Rollback() |
| 102 | return dirtyDirectories, err |
| 103 | } |
| 104 | if record.TopicID != "" { |
| 105 | affected[TopicKey{Scope: record.Scope, WorkspaceRoot: record.WorkspaceRoot, |
| 106 | workspaceKey: c.workspaceRootKey(record.Scope, record.WorkspaceRoot), TopicID: record.TopicID}] = struct{}{} |
| 107 | } |
| 108 | if err := c.updateFoldedTopicTombstones(ctx, tx, previous, record, c.opts.Now().UnixMilli()); err != nil { |
| 109 | _ = tx.Rollback() |
| 110 | return dirtyDirectories, err |
| 111 | } |
| 112 | roots[record.WorkspaceRoot] = struct{}{} |
| 113 | } |
| 114 | for key := range affected { |
| 115 | if err := c.recomputeTopic(ctx, tx, key); err != nil { |
| 116 | _ = tx.Rollback() |
| 117 | return dirtyDirectories, err |
| 118 | } |
| 119 | } |
| 120 | revision, err := bumpRevision(ctx, tx) |
| 121 | if err != nil { |
| 122 | _ = tx.Rollback() |
| 123 | return dirtyDirectories, err |
| 124 | } |
| 125 | if err := tx.Commit(); err != nil { |
| 126 | return dirtyDirectories, err |
| 127 | } |
| 128 | if notify { |
| 129 | c.publishRevision(revision, mapKeys(roots), reason) |
| 130 | } else { |
| 131 | c.rememberRevision(revision) |
| 132 | } |
| 133 | c.refreshCounts(ctx) |
| 134 | return dirtyDirectories, nil |
| 135 | } |
| 136 | |
| 137 | const sessionInsertSQL = `INSERT INTO catalog_sessions( |
| 138 | path,path_key,directory,directory_key,scope,workspace_root,workspace_root_key,topic_id,topic_title,custom_title, |
| 139 | created_at,last_activity_at,preview,turns,turns_state,recovered, |
| 140 | recovery_reason,recovery_digest,parent_id,recovery_copy,recovery_group_id, |
| 141 | recovery_role,recovery_canonical,logical_topic_id,ordinary_visible,content_fingerprint, |
| 142 | meta_fingerprint,health,missing_since,seen_generation |
| 143 | ,repair_state,repair_attempts,repair_retry_at,repair_error_kind,repair_source_fingerprint,repair_engine_version |
| 144 | ,log_format,head_count,selected_head_id |
| 145 | ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |
| 146 | ON CONFLICT(path_key) DO UPDATE SET ` |
| 147 | |
| 148 | const repairScheduleUpdateSQL = ` |
| 149 | repair_state=CASE |
| 150 | WHEN excluded.turns_state<>'unknown' THEN 'complete' |
| 151 | WHEN catalog_sessions.repair_source_fingerprint<>excluded.repair_source_fingerprint |
| 152 | OR catalog_sessions.repair_engine_version<>excluded.repair_engine_version THEN 'pending' |
| 153 | ELSE catalog_sessions.repair_state END, |
| 154 | repair_attempts=CASE |
| 155 | WHEN excluded.turns_state<>'unknown' |
| 156 | OR catalog_sessions.repair_source_fingerprint<>excluded.repair_source_fingerprint |
| 157 | OR catalog_sessions.repair_engine_version<>excluded.repair_engine_version THEN 0 |
| 158 | ELSE catalog_sessions.repair_attempts END, |
| 159 | repair_retry_at=CASE |
| 160 | WHEN excluded.turns_state<>'unknown' |
| 161 | OR catalog_sessions.repair_source_fingerprint<>excluded.repair_source_fingerprint |
| 162 | OR catalog_sessions.repair_engine_version<>excluded.repair_engine_version THEN 0 |
| 163 | ELSE catalog_sessions.repair_retry_at END, |
| 164 | repair_error_kind=CASE |
| 165 | WHEN excluded.turns_state<>'unknown' |
| 166 | OR catalog_sessions.repair_source_fingerprint<>excluded.repair_source_fingerprint |
| 167 | OR catalog_sessions.repair_engine_version<>excluded.repair_engine_version THEN '' |
| 168 | ELSE catalog_sessions.repair_error_kind END, |
| 169 | repair_source_fingerprint=excluded.repair_source_fingerprint, |
| 170 | repair_engine_version=excluded.repair_engine_version, |
| 171 | log_format=excluded.log_format, head_count=excluded.head_count, selected_head_id=excluded.selected_head_id` |
| 172 | |
| 173 | const directoryProjectionUpdateSQL = ` |
| 174 | path=excluded.path, directory=excluded.directory, directory_key=excluded.directory_key, scope=excluded.scope, |
| 175 | workspace_root=excluded.workspace_root, workspace_root_key=excluded.workspace_root_key, topic_id=excluded.topic_id, |
| 176 | topic_title=excluded.topic_title, custom_title=excluded.custom_title, |
| 177 | created_at=excluded.created_at, last_activity_at=excluded.last_activity_at, |
| 178 | preview=excluded.preview, turns=excluded.turns, |
| 179 | turns_state=excluded.turns_state, recovered=excluded.recovered, |
| 180 | recovery_reason=excluded.recovery_reason, |
| 181 | recovery_digest=excluded.recovery_digest, parent_id=excluded.parent_id, |
| 182 | recovery_copy=excluded.recovery_copy, |
| 183 | recovery_group_id=excluded.recovery_group_id, |
| 184 | recovery_role=excluded.recovery_role, |
| 185 | recovery_canonical=excluded.recovery_canonical, |
| 186 | logical_topic_id=excluded.logical_topic_id, |
| 187 | ordinary_visible=excluded.ordinary_visible, |
| 188 | content_fingerprint=excluded.content_fingerprint, |
| 189 | meta_fingerprint=excluded.meta_fingerprint, health=excluded.health, |
| 190 | missing_since=0, seen_generation=MAX(catalog_sessions.seen_generation, excluded.seen_generation),` + repairScheduleUpdateSQL |
| 191 | |
| 192 | const exactSourceUpdateSQL = ` |
| 193 | path=excluded.path, directory=excluded.directory, directory_key=excluded.directory_key, scope=excluded.scope, |
| 194 | workspace_root=excluded.workspace_root, workspace_root_key=excluded.workspace_root_key, |
| 195 | topic_id=CASE |
| 196 | WHEN catalog_sessions.recovered=1 OR excluded.recovered=1 OR catalog_sessions.recovery_group_id<>'' |
| 197 | THEN catalog_sessions.topic_id ELSE excluded.topic_id END, |
| 198 | topic_title=CASE |
| 199 | WHEN catalog_sessions.recovered=1 OR excluded.recovered=1 OR catalog_sessions.recovery_group_id<>'' |
| 200 | THEN catalog_sessions.topic_title ELSE excluded.topic_title END, |
| 201 | custom_title=excluded.custom_title, |
| 202 | created_at=excluded.created_at, last_activity_at=excluded.last_activity_at, |
| 203 | preview=excluded.preview, turns=excluded.turns, |
| 204 | turns_state=excluded.turns_state, recovered=excluded.recovered, |
| 205 | recovery_reason=excluded.recovery_reason, |
| 206 | recovery_digest=excluded.recovery_digest, parent_id=excluded.parent_id, |
| 207 | recovery_copy=catalog_sessions.recovery_copy, |
| 208 | recovery_group_id=catalog_sessions.recovery_group_id, |
| 209 | recovery_role=catalog_sessions.recovery_role, |
| 210 | recovery_canonical=catalog_sessions.recovery_canonical, |
| 211 | logical_topic_id=catalog_sessions.logical_topic_id, |
| 212 | ordinary_visible=catalog_sessions.ordinary_visible, |
| 213 | content_fingerprint=excluded.content_fingerprint, |
| 214 | meta_fingerprint=excluded.meta_fingerprint, health=excluded.health, |
| 215 | missing_since=0, seen_generation=MAX(catalog_sessions.seen_generation, excluded.seen_generation),` + repairScheduleUpdateSQL |
| 216 | |
| 217 | func (c *Catalog) upsertSessionRow(ctx context.Context, tx *sql.Tx, record SessionRecord, pathKey, directoryKey string, generation int64, mode sessionUpsertMode) error { |
| 218 | updateSQL := directoryProjectionUpdateSQL |
| 219 | if mode == upsertExactSource { |
| 220 | updateSQL = exactSourceUpdateSQL |
| 221 | } |
| 222 | if _, err := tx.ExecContext(ctx, sessionInsertSQL+updateSQL, c.sessionRowValues(record, pathKey, directoryKey, generation)...); err != nil { |
| 223 | return err |
| 224 | } |
| 225 | return upsertHeadRows(ctx, tx, pathKey, record.heads) |
| 226 | } |
| 227 | |
| 228 | func (c *Catalog) sessionRowValues(record SessionRecord, pathKey, directoryKey string, generation int64) []any { |
| 229 | repairState := "complete" |
| 230 | if record.TurnsState == TurnsUnknown { |
| 231 | repairState = "pending" |
| 232 | } |
| 233 | return []any{ |
| 234 | record.Path, pathKey, record.Directory, directoryKey, record.Scope, record.WorkspaceRoot, |
| 235 | c.workspaceRootKey(record.Scope, record.WorkspaceRoot), record.TopicID, record.TopicTitle, record.CustomTitle, record.CreatedAt, |
| 236 | record.LastActivityAt, record.Preview, record.Turns, record.TurnsState, |
| 237 | record.Recovered, record.RecoveryReason, record.RecoveryDigest, |
| 238 | record.ParentID, boolToInt(record.RecoveryCopy), record.RecoveryGroupID, |
| 239 | record.RecoveryRole, boolToInt(record.RecoveryCanonical), |
| 240 | record.LogicalTopicID, boolToInt(record.OrdinaryVisible), |
| 241 | record.ContentFingerprint, record.MetaFingerprint, |
| 242 | record.Health, 0, generation, |
| 243 | repairState, 0, 0, "", repairSourceFingerprint(record), repairEngineVersion, |
| 244 | max(record.LogFormat, 1), record.HeadCount, record.SelectedHeadID, |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | func repairSourceFingerprint(record SessionRecord) string { |
| 249 | return record.ContentFingerprint + "\x00" + record.MetaFingerprint |
| 250 | } |
| 251 |