@@ -2,10 +2,13 @@ package handler
22
33import (
44 "context"
5+ "fmt"
56 "log"
7+ "strings"
68 "time"
79
810 "github.com/Mininglamp-OSS/octo-smart-summary/internal/model"
11+ "github.com/Mininglamp-OSS/octo-smart-summary/internal/timezone"
912 "gorm.io/gorm"
1013)
1114
@@ -34,6 +37,9 @@ const (
3437 cleanupSlowThreshold = 1 * time .Second
3538 // cleanupJitter 首次执行前等一段随机时间,避免多实例撞车 & 冷启动瞬间打 DB
3639 cleanupInitialDelay = 30 * time .Second
40+ // workspaceCleanupBatchSize bounds each cleanup transaction so a large
41+ // backlog does not hold locks across the entire workspace history table.
42+ workspaceCleanupBatchSize = 200
3743)
3844
3945// StartAgentSessionCleanup 启动 24h 定时清理 goroutine。
@@ -85,8 +91,9 @@ func StartAgentSessionCleanup(ctx context.Context, db *gorm.DB) {
8591// summarize_chunk. Evidence expiry uses its own last-activity timestamp, while
8692// durable workspace references below prevent premature collection.
8793func runOnce (db * gorm.DB ) {
88- cutoff := time .Now ().Add (- cleanupAge )
89- start := time .Now ()
94+ now := timezone .Now ()
95+ cutoff := now .Add (- cleanupAge )
96+ start := timezone .Now ()
9097
9198 // 只在 Legacy 消息(space_id = '')中按 (user_id, session_id) 聚合
9299 // MAX(created_at),定位两键都过期的 tuple。workspace 消息由持久化 session
@@ -128,6 +135,18 @@ func runOnce(db *gorm.DB) {
128135 elapsed , result .RowsAffected , cutoff .Format (time .RFC3339 ))
129136 }
130137
138+ workspaceStart := timezone .Now ()
139+ workspaceCount , workspaceErr := cleanupExpiredSummaryWorkspaces (db , now )
140+ workspaceElapsed := time .Since (workspaceStart )
141+ if workspaceErr != nil {
142+ log .Printf ("[agent-cleanup] ERROR workspace cleanup failed after %s: %v" , workspaceElapsed , workspaceErr )
143+ } else if workspaceCount > 0 {
144+ log .Printf ("[agent-cleanup] cleaned %d expired workspace sessions in %s" , workspaceCount , workspaceElapsed )
145+ }
146+ if workspaceElapsed > cleanupSlowThreshold {
147+ log .Printf ("[agent-cleanup] SLOW workspace cleanup took %s (sessions=%d)" , workspaceElapsed , workspaceCount )
148+ }
149+
131150 // #161 P2 (yujiawei): symmetric evidence cleanup. Delete evidence rows
132151 // for (user_id, session_id) tuples whose evidence itself is older than
133152 // cleanupAge. Keying off evidence.created_at (not agent_message) is
@@ -138,7 +157,7 @@ func runOnce(db *gorm.DB) {
138157 // agent_session_id. Preserve every (user_id, agent_session_id) tuple referenced
139158 // by agent_summary_session; workspace retirement must remove that session
140159 // before this Legacy cleanup may collect its evidence.
141- evStart := time .Now ()
160+ evStart := timezone .Now ()
142161 evResult := db .Exec (`
143162 DELETE FROM agent_message_evidence
144163 WHERE (user_id, session_id) IN (
@@ -171,6 +190,112 @@ func runOnce(db *gorm.DB) {
171190 }
172191}
173192
193+ // cleanupExpiredSummaryWorkspaces retires inactive workspace state after the
194+ // 30-day sliding retention window maintained by AgentWorkspaceStore. Rows
195+ // created before expires_at was populated fall back to updated_at so rollout
196+ // does not leave permanent NULL tombstones.
197+ func cleanupExpiredSummaryWorkspaces (db * gorm.DB , now time.Time ) (int64 , error ) {
198+ if db == nil {
199+ return 0 , fmt .Errorf ("workspace cleanup database is required" )
200+ }
201+ legacyCutoff := now .Add (- summaryWorkspaceRetention )
202+ var cleaned int64
203+ for {
204+ var sessions []model.AgentSummarySession
205+ if err := db .Where ("expires_at <= ? OR (expires_at IS NULL AND updated_at <= ?)" , now , legacyCutoff ).
206+ Order ("id ASC" ).Limit (workspaceCleanupBatchSize ).Find (& sessions ).Error ; err != nil {
207+ return cleaned , fmt .Errorf ("load expired workspace sessions: %w" , err )
208+ }
209+ if len (sessions ) == 0 {
210+ break
211+ }
212+ if err := db .Transaction (func (tx * gorm.DB ) error {
213+ for _ , session := range sessions {
214+ if err := deleteWorkspaceSessionState (tx , session ); err != nil {
215+ return err
216+ }
217+ }
218+ return nil
219+ }); err != nil {
220+ return cleaned , err
221+ }
222+ cleaned += int64 (len (sessions ))
223+ if len (sessions ) < workspaceCleanupBatchSize {
224+ break
225+ }
226+ }
227+
228+ // Idempotency bindings only need to outlive their live task. Retire old
229+ // orphan/tombstone rows after the same window while preserving bindings for
230+ // summaries that still exist.
231+ if err := db .Exec (`
232+ DELETE FROM summary_workflow_idempotency
233+ WHERE created_at <= ?
234+ AND NOT EXISTS (
235+ SELECT 1 FROM summary_task
236+ WHERE summary_task.id = summary_workflow_idempotency.task_id
237+ AND summary_task.deleted_at IS NULL
238+ )
239+ ` , legacyCutoff ).Error ; err != nil {
240+ return cleaned , fmt .Errorf ("clean workflow idempotency tombstones: %w" , err )
241+ }
242+ return cleaned , nil
243+ }
244+
245+ func deleteWorkspaceSessionState (tx * gorm.DB , session model.AgentSummarySession ) error {
246+ var runIDs []string
247+ if err := tx .Model (& model.AgentMessage {}).
248+ Where ("space_id = ? AND user_id = ? AND session_id = ? AND run_id <> ''" , session .SpaceID , session .UserID , session .SessionID ).
249+ Distinct ().Pluck ("run_id" , & runIDs ).Error ; err != nil {
250+ return fmt .Errorf ("load workspace run ids for session %d: %w" , session .ID , err )
251+ }
252+
253+ evidenceSessions := make ([]string , 0 , len (runIDs )+ 1 )
254+ if strings .TrimSpace (session .AgentSessionID ) != "" {
255+ evidenceSessions = append (evidenceSessions , session .AgentSessionID )
256+ }
257+ if len (runIDs ) > 0 {
258+ var runs []model.AgentSummaryRun
259+ if err := tx .Select ("run_id" , "session_id" ).Where ("user_id = ? AND run_id IN ?" , session .UserID , runIDs ).Find (& runs ).Error ; err != nil {
260+ return fmt .Errorf ("load workspace runs for session %d: %w" , session .ID , err )
261+ }
262+ for _ , run := range runs {
263+ if strings .TrimSpace (run .SessionID ) != "" {
264+ evidenceSessions = append (evidenceSessions , run .SessionID )
265+ }
266+ }
267+ if err := tx .Where ("run_id IN ?" , runIDs ).Delete (& model.AgentCitationManifest {}).Error ; err != nil {
268+ return fmt .Errorf ("delete workspace citation manifests for session %d: %w" , session .ID , err )
269+ }
270+ if err := tx .Where ("run_id IN ?" , runIDs ).Delete (& model.AgentEvidenceArtifact {}).Error ; err != nil {
271+ return fmt .Errorf ("delete workspace evidence artifacts for session %d: %w" , session .ID , err )
272+ }
273+ if err := tx .Where ("run_id IN ?" , runIDs ).Delete (& model.AgentSummarySpec {}).Error ; err != nil {
274+ return fmt .Errorf ("delete workspace specs for session %d: %w" , session .ID , err )
275+ }
276+ if err := tx .Where ("user_id = ? AND run_id IN ?" , session .UserID , runIDs ).Delete (& model.AgentSummaryRun {}).Error ; err != nil {
277+ return fmt .Errorf ("delete workspace runs for session %d: %w" , session .ID , err )
278+ }
279+ }
280+ if len (evidenceSessions ) > 0 {
281+ if err := tx .Where ("user_id = ? AND session_id IN ?" , session .UserID , evidenceSessions ).Delete (& model.AgentMessageEvidence {}).Error ; err != nil {
282+ return fmt .Errorf ("delete workspace evidence for session %d: %w" , session .ID , err )
283+ }
284+ }
285+ if err := tx .Where ("space_id = ? AND user_id = ? AND session_id = ?" , session .SpaceID , session .UserID , session .SessionID ).
286+ Delete (& model.AgentMessage {}).Error ; err != nil {
287+ return fmt .Errorf ("delete workspace messages for session %d: %w" , session .ID , err )
288+ }
289+ if err := tx .Where ("space_id = ? AND user_id = ? AND session_id = ?" , session .SpaceID , session .UserID , session .SessionID ).
290+ Delete (& model.AgentSummaryTurn {}).Error ; err != nil {
291+ return fmt .Errorf ("delete workspace turns for session %d: %w" , session .ID , err )
292+ }
293+ if err := tx .Where ("id = ?" , session .ID ).Delete (& model.AgentSummarySession {}).Error ; err != nil {
294+ return fmt .Errorf ("delete workspace session %d: %w" , session .ID , err )
295+ }
296+ return nil
297+ }
298+
174299// 兜底类型检查:确保 AgentMessage 表名不变时这段代码还生效
175300var _ = model.AgentMessage {}
176301var _ = model.AgentMessageEvidence {}
0 commit comments